diff --git a/server/repeater_test.go b/server/repeater_test.go index fe8ad1d..ac43887 100644 --- a/server/repeater_test.go +++ b/server/repeater_test.go @@ -2,12 +2,16 @@ package server import ( "bytes" + "crypto/x509" "encoding/json" + "errors" + "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" + "os" "strings" "testing" @@ -195,12 +199,12 @@ func TestRepeaterRejectsCrossHostRedirect(t *testing.T) { } } -func TestRepeaterSocks5TransportConstruction(t *testing.T) { +func TestRepeaterProxyTransportConstruction(t *testing.T) { s := &Server{m: &Manager{}} - for _, scheme := range []string{"socks5", "socks5h"} { + for _, scheme := range []string{"http", "https", "socks5", "socks5h"} { tr := s.httpProxyTransport(httpExec{Proxy: scheme + "://user:pass@127.0.0.1:1080"}) if tr == nil || tr.Proxy == nil { - t.Fatalf("%s transport not configured as SOCKS: %#v", scheme, tr) + t.Fatalf("%s proxy transport not configured: %#v", scheme, tr) } proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil)) if err != nil || proxyURL == nil || proxyURL.Scheme != scheme || proxyURL.Host != "127.0.0.1:1080" { @@ -245,4 +249,39 @@ func TestRepeaterSocks5TransportConstruction(t *testing.T) { if err != nil || proxyURL == nil || proxyURL.Scheme != "http" || proxyURL.Host != "127.0.0.1:18788" { t.Fatalf("recording proxy URL = %#v, err=%v", proxyURL, err) } + caFile := s.m.ProxyCACert() + if caFile == "" { + t.Fatal("recording proxy CA path is empty") + } + caPEM, err := os.ReadFile(caFile) + if err != nil { + t.Fatalf("read recording proxy CA: %v", err) + } + wantRoots := x509.NewCertPool() + if !wantRoots.AppendCertsFromPEM(caPEM) { + t.Fatal("recording proxy CA is not valid PEM") + } + if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil || !tr.TLSClientConfig.RootCAs.Equal(wantRoots) { + t.Fatal("recording proxy CA was not loaded into transport RootCAs") + } +} + +func TestRepeaterRedactProxyCredentials(t *testing.T) { + for _, scheme := range []string{"http", "https", "socks5", "socks5h"} { + t.Run(scheme, func(t *testing.T) { + proxyErr := &url.Error{ + Op: "proxyconnect", URL: scheme + "://proxy-user:proxy-secret@127.0.0.1:1080", + Err: errors.New("connection refused"), + } + for _, err := range []error{proxyErr, fmt.Errorf("send request: %w", proxyErr)} { + got := repeaterRedactError(err) + if strings.Contains(got, "proxy-secret") || strings.Contains(got, "proxy-user") { + t.Fatalf("proxy credentials leaked: %q", got) + } + if got != "proxyconnect 127.0.0.1:1080: connection refused" { + t.Fatalf("unexpected redacted error: %q", got) + } + } + }) + } } diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx index f925e23..041b87e 100644 --- a/web/src/components/finding-repeater-dialog.tsx +++ b/web/src/components/finding-repeater-dialog.tsx @@ -110,24 +110,40 @@ export function FindingRepeaterDialog({ const [response, setResponse] = React.useState(null); const [history, setHistory] = React.useState([]); const sequence = React.useRef(0); + const generation = React.useRef(0); + const sending = React.useRef(false); React.useEffect(() => { + sending.current = false; + setBusy(false); if (!open) return; const nextMethod = (initial?.method ?? "GET").toUpperCase(); setMethod(METHODS.includes(nextMethod as (typeof METHODS)[number]) ? nextMethod : "GET"); setURL(initial?.url ?? ""); setHeadersText(initial?.headersText ?? ""); setBody(initial?.body ?? ""); + setUseProxy(true); + setInsecure(false); setResponse(null); + return () => { + // Closing, reseeding, or unmounting makes any pending result obsolete. + generation.current += 1; + sending.current = false; + }; }, [open, initial?.method, initial?.url, initial?.headersText, initial?.body]); async function send() { + // State alone cannot stop a second click before React disables the button. + if (sending.current || !open) return; const target = url.trim(); if (!target) { toast.error("请填写 URL"); return; } + const requestGeneration = ++generation.current; + sending.current = true; setBusy(true); + setResponse(null); try { const result = await api.repeaterSend({ method, @@ -137,23 +153,38 @@ export function FindingRepeaterDialog({ use_recording_proxy: useProxy, insecure_skip_verify: insecure, }); + if (requestGeneration !== generation.current) return; setResponse(result); - sequence.current += 1; + const id = ++sequence.current; setHistory((previous) => [ - { id: sequence.current, method, url: target, headersText, body, useProxy, insecure, response: result }, + { id, method, url: target, headersText, body, useProxy, insecure, response: result }, ...previous, ].slice(0, 20), ); } catch (error) { - toast.error((error as Error).message); + if (requestGeneration === generation.current) toast.error((error as Error).message); } finally { - setBusy(false); + if (requestGeneration === generation.current) { + sending.current = false; + setBusy(false); + } } } return ( - + { + if (!nextOpen) { + // Invalidate immediately, before the parent's close render/effect. + generation.current += 1; + sending.current = false; + setBusy(false); + } + onOpenChange(nextOpen); + }} + > Repeater · 请求重放 @@ -164,7 +195,7 @@ export function FindingRepeaterDialog({
- @@ -182,6 +213,7 @@ export function FindingRepeaterDialog({
setURL(event.target.value)} placeholder="https://target.example/path" @@ -197,6 +229,7 @@ export function FindingRepeaterDialog({