diff --git a/server/customtool.go b/server/customtool.go index 4d9e30e..bfd76bc 100644 --- a/server/customtool.go +++ b/server/customtool.go @@ -462,9 +462,17 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport { proxyStr := strings.TrimSpace(spec.Proxy) var caFile string if spec.UseRecordingProxy { - if addr := s.m.ProxyAddr(); addr != "" { - proxyStr = "http://" + addr - caFile = s.m.ProxyCACert() + // ProxyAddr is the local MITM address while capture is enabled, but + // returns the configured global proxy URL when capture is off. Keep the + // latter as a complete URL (including socks5:// and credentials) instead + // of prepending http:// to it. + if s.m.TrafficEnabled() { + if addr := s.m.ProxyAddr(); addr != "" { + proxyStr = "http://" + addr + caFile = s.m.ProxyCACert() + } + } else if global := strings.TrimSpace(s.m.GlobalProxy()); global != "" { + proxyStr = global } } if proxyStr == "" { @@ -474,6 +482,9 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport { if err != nil { return nil } + // Go 1.26+ supports http/https/socks5/socks5h in ProxyURL directly, + // including proxy userinfo. Keep one code path so existing HTTP tool + // behavior and proxy authentication remain unchanged. tr := &http.Transport{Proxy: http.ProxyURL(pu)} if caFile != "" { if pem, err := os.ReadFile(caFile); err == nil { diff --git a/server/repeater.go b/server/repeater.go new file mode 100644 index 0000000..9a9b550 --- /dev/null +++ b/server/repeater.go @@ -0,0 +1,323 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" + "time" +) + +const ( + repeaterMaxBody = 2 << 20 // 2 MiB, for both the JSON request and response preview + repeaterDefaultTimeout = 30_000 + repeaterMinTimeout = 1_000 + repeaterMaxTimeout = 120_000 + repeaterAllowLocalEnv = "ARTEX_REPEATER_ALLOW_LOCAL" +) + +var repeaterHopByHop = map[string]struct{}{ + "Connection": {}, "Keep-Alive": {}, "Proxy-Connection": {}, "Transfer-Encoding": {}, + "Te": {}, "Trailer": {}, "Upgrade": {}, "Content-Length": {}, "Proxy-Authorization": {}, +} + +type repeaterSendRequest struct { + Method *string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body string `json:"body"` + TimeoutMs int `json:"timeout_ms"` + InsecureSkipVerify bool `json:"insecure_skip_verify"` + UseRecordingProxy *bool `json:"use_recording_proxy"` +} + +type repeaterSendResponse struct { + Status int `json:"status"` + StatusText string `json:"status_text"` + Proto string `json:"proto"` + Headers map[string][]string `json:"headers"` + Body string `json:"body"` + BodyTruncated bool `json:"body_truncated"` + BodyBytes int `json:"body_bytes"` + ElapsedMs int64 `json:"elapsed_ms"` + URL string `json:"url"` + Error string `json:"error,omitempty"` +} + +func (s *Server) registerRepeater(mux *http.ServeMux) { + mux.HandleFunc("POST /api/repeater/send", s.sendRepeater) +} + +func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { + var in repeaterSendRequest + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, repeaterMaxBody)) + if err := dec.Decode(&in); err != nil { + writeErr(w, http.StatusBadRequest, "请求体无效") + return + } + // Reject a second JSON value (or oversized trailing data) instead of silently + // accepting bytes beyond the documented 2 MiB request limit. + var extra any + if err := dec.Decode(&extra); err != io.EOF { + writeErr(w, http.StatusBadRequest, "请求体无效") + return + } + + method := http.MethodGet + if in.Method != nil { + method = strings.ToUpper(strings.TrimSpace(*in.Method)) + } + if method == "" { + writeErr(w, http.StatusBadRequest, "HTTP 方法不能为空") + return + } + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodHead, http.MethodOptions: + default: + writeErr(w, http.StatusBadRequest, "不支持的 HTTP 方法") + return + } + + rawURL := strings.TrimSpace(in.URL) + if rawURL == "" { + writeErr(w, http.StatusBadRequest, "url 必填") + return + } + u, err := url.Parse(rawURL) + if err != nil || u.Host == "" || u.Hostname() == "" || + !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + writeErr(w, http.StatusBadRequest, "仅支持绝对 http/https URL") + return + } + // This check is needed even when a proxy is used: the proxy must remain usable + // for normal targets, while obvious literal metadata/loopback URLs stay blocked. + if err := repeaterCheckURL(u); err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + + timeoutMs := in.TimeoutMs + if timeoutMs <= 0 { + timeoutMs = repeaterDefaultTimeout + } + if timeoutMs < repeaterMinTimeout { + timeoutMs = repeaterMinTimeout + } + if timeoutMs > repeaterMaxTimeout { + timeoutMs = repeaterMaxTimeout + } + useRecordingProxy := true + if in.UseRecordingProxy != nil { + useRecordingProxy = *in.UseRecordingProxy + } + + var body io.Reader + if in.Body != "" && method != http.MethodHead { + body = strings.NewReader(in.Body) + } + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + writeErr(w, http.StatusBadRequest, "构造请求失败") + return + } + for key, value := range in.Headers { + name := strings.TrimSpace(key) + if name == "" { + continue + } + if _, blocked := repeaterHopByHop[http.CanonicalHeaderKey(name)]; blocked { + continue + } + if strings.EqualFold(name, "Host") { + // Host is a special Request field, but retaining it in Header too makes + // the editor semantics explicit; net/http uses req.Host on the wire. + req.Host = strings.TrimSpace(value) + req.Header.Set("Host", strings.TrimSpace(value)) + continue + } + req.Header.Set(name, value) + } + + client := &http.Client{ + Timeout: time.Duration(timeoutMs) * time.Millisecond, + Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify), + CheckRedirect: func(next *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("重定向次数过多") + } + if err := repeaterCheckURL(next.URL); err != nil { + return err + } + if len(via) > 0 && !strings.EqualFold(next.URL.Host, via[0].URL.Host) { + return fmt.Errorf("拒绝跨主机重定向(%s → %s)", via[0].URL.Host, next.URL.Host) + } + return nil + }, + } + + started := time.Now() + resp, err := client.Do(req) + if err != nil { + writeJSON(w, http.StatusOK, repeaterSendResponse{ + Headers: map[string][]string{}, URL: u.String(), + ElapsedMs: time.Since(started).Milliseconds(), Error: repeaterRedactError(err), + }) + return + } + defer resp.Body.Close() + + out := repeaterSendResponse{ + Status: resp.StatusCode, StatusText: resp.Status, Proto: resp.Proto, + Headers: resp.Header.Clone(), URL: resp.Request.URL.String(), + } + if method != http.MethodHead { + // Read one byte beyond the cap so truncation is exact. The MVP keeps the + // preview as UTF-8 text; invalid bytes are replaced instead of base64. + data, readErr := io.ReadAll(io.LimitReader(resp.Body, repeaterMaxBody+1)) + if len(data) > repeaterMaxBody { + out.BodyTruncated = true + data = data[:repeaterMaxBody] + } + out.Body = repeaterBodyText(data) + out.BodyBytes = len(data) + if readErr != nil && !errors.Is(readErr, io.EOF) { + out.Error = "读取响应失败: " + readErr.Error() + } + } + out.ElapsedMs = time.Since(started).Milliseconds() + writeJSON(w, http.StatusOK, out) +} + +// repeaterBodyText keeps the response preview as text for the MVP. Invalid +// UTF-8 and NUL bytes are replaced so a binary response cannot inject control +// characters into the JSON/UI raw view; the original bytes are not base64 +// encoded and the preview remains bounded by repeaterMaxBody. +func repeaterBodyText(data []byte) string { + text := string(bytes.ToValidUTF8(data, []byte("�"))) + return strings.ReplaceAll(text, "\x00", "�") +} + +func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundTripper { + var transport *http.Transport + proxied := false + if useRecordingProxy && s != nil && s.m != nil { + transport = s.httpProxyTransport(httpExec{UseRecordingProxy: true}) + proxied = transport != nil + } + if transport == nil && s != nil && s.m != nil && strings.TrimSpace(s.m.GlobalProxy()) != "" { + transport = s.httpProxyTransport(httpExec{Proxy: s.m.GlobalProxy()}) + proxied = transport != nil + } + if transport == nil { + if base, ok := http.DefaultTransport.(*http.Transport); ok { + transport = base.Clone() + } else { + transport = &http.Transport{} + } + // "Direct" means direct even if the process inherited HTTP_PROXY. The + // configured recording/global proxy paths are handled above explicitly. + transport.Proxy = nil + } + transport = transport.Clone() + // Keep response bytes faithful and make body_bytes match what was received; + // transparent gzip decompression would otherwise obscure both values. + transport.DisableCompression = true + if !proxied { + // Clear a custom TLS dialer before installing the guarded dialer; when + // DialTLSContext is set, net/http bypasses DialContext entirely. + transport.DialTLSContext = nil + transport.DialTLS = nil + transport.Dial = nil + transport.DialContext = (&net.Dialer{ + Timeout: 15 * time.Second, KeepAlive: 30 * time.Second, + Control: repeaterBlockSensitiveDial, + }).DialContext + } + if insecure { + cfg := &tls.Config{InsecureSkipVerify: true} //nolint:gosec // explicit user option for pentest targets + if transport.TLSClientConfig != nil { + cfg = transport.TLSClientConfig.Clone() + cfg.InsecureSkipVerify = true + } + transport.TLSClientConfig = cfg + } + return transport +} + +func repeaterAllowLocal() bool { + v := strings.TrimSpace(os.Getenv(repeaterAllowLocalEnv)) + return v == "1" || strings.EqualFold(v, "true") +} + +func repeaterCheckURL(u *url.URL) error { + if repeaterAllowLocal() { + return nil + } + host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".") + // URL.Hostname may retain an IPv6 zone (for example fe80::1%eth0); the + // zone is not part of the address classification. + if zone := strings.IndexByte(host, '%'); zone >= 0 { + host = host[:zone] + } + ip := net.ParseIP(host) + if strings.EqualFold(host, "localhost") || (ip != nil && repeaterIsBlockedDialIP(ip)) { + return fmt.Errorf("拒绝访问本机/链路本地地址(如确需访问本机服务,设置 %s=1)", repeaterAllowLocalEnv) + } + return nil +} + +func repeaterIsBlockedDialIP(ip net.IP) bool { + if ip == nil { + return true + } + if v4 := ip.To4(); v4 != nil { + ip = v4 + } + return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsInterfaceLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() +} + +func repeaterBlockSensitiveDial(_, address string, _ syscall.RawConn) error { + if repeaterAllowLocal() { + return nil + } + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("无法解析目标地址 %q", host) + } + if repeaterIsBlockedDialIP(ip) { + return fmt.Errorf("拒绝访问本机/链路本地地址 %s(如确需访问本机服务,设置 %s=1)", ip, repeaterAllowLocalEnv) + } + return nil +} + +func repeaterRedactError(err error) string { + var urlErr *url.Error + if errors.As(err, &urlErr) { + host := "" + if parsed, parseErr := url.Parse(urlErr.URL); parseErr == nil { + host = parsed.Host + } + if urlErr.Err != nil { + return fmt.Sprintf("%s %s: %s", urlErr.Op, host, urlErr.Err) + } + return fmt.Sprintf("%s %s: 未知错误", urlErr.Op, host) + } + return err.Error() +} diff --git a/server/repeater_test.go b/server/repeater_test.go new file mode 100644 index 0000000..508a7e2 --- /dev/null +++ b/server/repeater_test.go @@ -0,0 +1,186 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func repeaterTestSend(t *testing.T, s *Server, payload any) (int, repeaterSendResponse, string) { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", bytes.NewReader(raw)) + w := httptest.NewRecorder() + s.sendRepeater(w, r) + var out repeaterSendResponse + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out, w.Body.String() +} + +func TestRepeaterRejectsInvalidInput(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "") + s := &Server{} + for _, tc := range []struct { + name string + body string + }{ + {"missing url", `{"method":"GET"}`}, + {"empty method", `{"method":" ","url":"https://example.test/"}`}, + {"bad scheme", `{"method":"GET","url":"file:///etc/passwd"}`}, + {"gopher", `{"method":"GET","url":"gopher://example.test/1"}`}, + {"unknown method", `{"method":"TRACE","url":"http://example.test/"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", strings.NewReader(tc.body)) + w := httptest.NewRecorder() + s.sendRepeater(w, r) + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d want 400 body=%s", w.Code, w.Body.String()) + } + }) + } +} + +func TestRepeaterBlockedIPs(t *testing.T) { + for _, raw := range []string{"127.0.0.1", "::1", "169.254.169.254", "0.0.0.0", "224.0.0.1"} { + if !repeaterIsBlockedDialIP(net.ParseIP(raw)) { + t.Errorf("expected %s blocked", raw) + } + } + for _, raw := range []string{"8.8.8.8", "10.0.0.1", "192.168.1.20", "172.16.0.5"} { + if repeaterIsBlockedDialIP(net.ParseIP(raw)) { + t.Errorf("expected %s allowed", raw) + } + } + + t.Setenv(repeaterAllowLocalEnv, "") + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("loopback handler should not be reached") + })) + defer srv.Close() + code, _, body := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false}) + if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") { + t.Fatalf("loopback status=%d body=%s", code, body) + } +} + +func TestRepeaterRoundTripHeadersAndBody(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + var got http.Request + var gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = *r + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Set("X-Echo", "ok") + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, "hello") + })) + defer srv.Close() + + code, out, body := repeaterTestSend(t, &Server{}, map[string]any{ + "method": "POST", "url": srv.URL, "headers": map[string]string{ + "X-Test": "yes", "Host": "custom.example", "Connection": "keep-alive", + "Keep-Alive": "timeout=5", "Transfer-Encoding": "chunked", "Content-Length": "99", + }, "body": "payload", "use_recording_proxy": false, + }) + if code != http.StatusOK || out.Error != "" { + t.Fatalf("status=%d out=%+v raw=%s", code, out, body) + } + if out.Status != http.StatusCreated || out.Body != "hello" || out.BodyBytes != 5 { + t.Fatalf("response=%+v", out) + } + if out.Headers["X-Echo"][0] != "ok" || got.Method != http.MethodPost || gotBody != "payload" || got.Host != "custom.example" { + t.Fatalf("request method=%s host=%s body=%q headers=%v", got.Method, got.Host, gotBody, got.Header) + } + for _, name := range []string{"Connection", "Keep-Alive", "Transfer-Encoding"} { + if got.Header.Get(name) != "" { + t.Errorf("hop-by-hop header %s was forwarded: %q", name, got.Header.Get(name)) + } + } + if got.Header.Get("Content-Length") != "7" { + t.Errorf("content length should match body, got %q", got.Header.Get("Content-Length")) + } +} + +func TestRepeaterTruncatesAndHonoursHEAD(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + large := strings.Repeat("x", repeaterMaxBody+1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodHead { + _, _ = io.WriteString(w, "should-not-be-read") + return + } + _, _ = io.WriteString(w, large) + })) + defer srv.Close() + _, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": srv.URL, "use_recording_proxy": false}) + if !out.BodyTruncated || out.BodyBytes != repeaterMaxBody || len(out.Body) != repeaterMaxBody { + t.Fatalf("truncation=%+v", out) + } + _, head, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "HEAD", "url": srv.URL, "use_recording_proxy": false}) + if head.Body != "" || head.BodyBytes != 0 { + t.Fatalf("HEAD body=%q bytes=%d", head.Body, head.BodyBytes) + } +} + +func TestRepeaterBodyTextReplacesBinaryControls(t *testing.T) { + if got := repeaterBodyText([]byte{'a', 0, 0xff, 'b'}); got != "a��b" { + t.Fatalf("body text=%q", got) + } +} + +func TestRepeaterRejectsCrossHostRedirect(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "target") })) + defer target.Close() + redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL, http.StatusFound) + })) + defer redirect.Close() + _, out, _ := repeaterTestSend(t, &Server{}, map[string]any{"url": redirect.URL, "use_recording_proxy": false}) + if out.Status != 0 || !strings.Contains(out.Error, "跨主机") { + t.Fatalf("redirect response=%+v", out) + } +} + +func TestRepeaterSocks5TransportConstruction(t *testing.T) { + s := &Server{m: &Manager{}} + for _, scheme := range []string{"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) + } + 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" { + t.Fatalf("%s proxy URL = %#v, err=%v", scheme, proxyURL, err) + } + if user := proxyURL.User; user == nil || user.Username() != "user" { + t.Fatalf("%s proxy credentials missing: %v", scheme, proxyURL.User) + } + password, ok := proxyURL.User.Password() + if !ok || password != "pass" { + t.Fatalf("%s proxy password missing: %q (ok=%v)", scheme, password, ok) + } + } + + // With capture disabled, the recording-proxy option must preserve the + // configured global proxy URL instead of treating it as a host:port. + s.m.globalProxy = "socks5://user:pass@127.0.0.1:1080" + tr := s.httpProxyTransport(httpExec{UseRecordingProxy: true}) + if tr == nil || tr.Proxy == nil { + t.Fatal("global SOCKS5 proxy was not configured") + } + proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil)) + if err != nil || proxyURL == nil || proxyURL.Scheme != "socks5" { + t.Fatalf("global proxy URL = %#v, err=%v", proxyURL, err) + } +} diff --git a/server/server.go b/server/server.go index 969216c..da6d755 100644 --- a/server/server.go +++ b/server/server.go @@ -824,6 +824,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport) s.registerFindingTraffic(mux) + s.registerRepeater(mux) mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding) diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx new file mode 100644 index 0000000..d609c2c --- /dev/null +++ b/web/src/components/finding-repeater-dialog.tsx @@ -0,0 +1,293 @@ +"use client"; + +import * as React from "react"; + +import { SendIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/lib/api"; +import type { RepeaterSendResponse } from "@/lib/types"; + +const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const; + +export type RepeaterInitial = { + method?: string; + url?: string; + headersText?: string; + body?: string; +}; + +type HistoryEntry = { + id: number; + method: string; + url: string; + headersText: string; + body: string; + useProxy: boolean; + insecure: boolean; + response: RepeaterSendResponse; +}; + +export function parseHeadersText(text: string): Record { + const result: Record = {}; + for (const line of text.replace(/\r\n?/g, "\n").split("\n")) { + const separator = line.indexOf(":"); + if (separator <= 0) continue; + const name = line.slice(0, separator).trim(); + const value = line.slice(separator + 1).trim(); + if (!name) continue; + // The API accepts one value per name. Combining duplicates preserves the + // common comma-separated-header semantics for this MVP. + result[name] = result[name] ? `${result[name]}, ${value}` : value; + } + return result; +} + +export function formatHeaders(headers: Record | Record): string { + return Object.entries(headers) + .flatMap(([name, value]) => + Array.isArray(value) ? value.map((item) => `${name}: ${item}`) : [`${name}: ${value}`], + ) + .join("\n"); +} + +export function buildRawResponse(response: RepeaterSendResponse): string { + if (!response.status && response.error) return `错误: ${response.error}`; + const statusLine = `${response.proto || "HTTP/1.1"} ${response.status_text || response.status}`.trim(); + const headers = formatHeaders(response.headers); + return `${statusLine}\n${headers}\n\n${response.body}`; +} + +export function headersFromReqHead(reqHead?: string): string { + if (!reqHead) return ""; + const lines = reqHead.replace(/\r\n?/g, "\n").split("\n"); + // Evidence heads normally start with `METHOD path HTTP/x.y`, but tolerate + // leading blank lines and CR-only captures from older traffic records. + let requestLine = -1; + for (let index = 0; index < lines.length; index += 1) { + const candidate = lines[index].trim(); + if (!candidate) continue; + if (/^(?:[A-Z]+)\s+\S+\s+HTTP\/\d(?:\.\d)?$/i.test(candidate)) requestLine = index; + break; + } + const start = requestLine >= 0 ? requestLine + 1 : 0; + return lines.slice(start).join("\n").replace(/\n+$/, ""); +} + +export function FindingRepeaterDialog({ + open, + onOpenChange, + initial, + findingLabel, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + initial?: RepeaterInitial; + findingLabel?: string; +}) { + const [method, setMethod] = React.useState("GET"); + const [url, setURL] = React.useState(""); + const [headersText, setHeadersText] = React.useState(""); + const [body, setBody] = React.useState(""); + const [useProxy, setUseProxy] = React.useState(true); + const [insecure, setInsecure] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [response, setResponse] = React.useState(null); + const [history, setHistory] = React.useState([]); + const sequence = React.useRef(0); + + React.useEffect(() => { + 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 ?? ""); + setResponse(null); + }, [open, initial?.method, initial?.url, initial?.headersText, initial?.body]); + + async function send() { + const target = url.trim(); + if (!target) { + toast.error("请填写 URL"); + return; + } + setBusy(true); + try { + const result = await api.repeaterSend({ + method, + url: target, + headers: parseHeadersText(headersText), + body, + use_recording_proxy: useProxy, + insecure_skip_verify: insecure, + }); + setResponse(result); + sequence.current += 1; + setHistory((previous) => + [ + { id: sequence.current, method, url: target, headersText, body, useProxy, insecure, response: result }, + ...previous, + ].slice(0, 20), + ); + } catch (error) { + toast.error((error as Error).message); + } finally { + setBusy(false); + } + } + + return ( + + + + Repeater · 请求重放 + + 手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告{findingLabel ? ` · ${findingLabel}` : ""} + + +
+
+ + +
+
+ + setURL(event.target.value)} + placeholder="https://target.example/path" + spellCheck={false} + /> +
+ +
+
+ + + {response ? ( +
+ {response.status ? {response.status_text || response.status} : null} + {response.elapsed_ms} ms + {response.body_bytes.toLocaleString()} 字节 + {response.body_truncated ? 正文已截断 : null} +
+ ) : null} +
+
+
+
会话历史
+
+ {history.length === 0 ? ( +

发送后会出现在这里

+ ) : ( + history.map((entry) => ( + + )) + )} +
+
+
+
请求
+
+
+ +