From 341caa5e1f02034345caa58c46dc86a902544007 Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Fri, 23 Oct 2026 19:22:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E5=8F=91=E7=8E=B0=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B1=BB=E4=BC=BC=20Burp=20Repeater=20?= =?UTF-8?q?=E7=9A=84=E8=AF=B7=E6=B1=82=E9=87=8D=E6=94=BE=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在发现详情的关联流量区提供 Repeater:可从流量证据一键预填,或空白编辑 方法/URL/头/体后由服务端代发请求并展示原始响应,便于人工验证与报告撰写。 - POST /api/repeater/send:复用 recording/global 代理出口 - 直连路径拨号层拦截环回/链路本地/元数据;字面敏感 IP 在 URL 预检拒绝 - 会话内历史(前端内存);跨主机重定向拒绝 --- server/repeater.go | 325 ++++++++++++++++++ server/repeater_test.go | 133 +++++++ server/server.go | 1 + .../components/finding-repeater-dialog.tsx | 308 +++++++++++++++++ web/src/components/finding-traffic-panel.tsx | 64 +++- .../components/traffic-evidence-viewer.tsx | 34 ++ web/src/lib/api.ts | 4 + web/src/lib/types.ts | 22 ++ 8 files changed, 884 insertions(+), 7 deletions(-) create mode 100644 server/repeater.go create mode 100644 server/repeater_test.go create mode 100644 web/src/components/finding-repeater-dialog.tsx diff --git a/server/repeater.go b/server/repeater.go new file mode 100644 index 0000000..c5a4fad --- /dev/null +++ b/server/repeater.go @@ -0,0 +1,325 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" + "time" +) + +// Repeater is a Burp-like request/response playground for findings verification. +// It intentionally reuses the same egress proxy plumbing as custom HTTP tools +// (recording proxy / global proxy) so manual verification sees the same path as +// agent traffic. + +const ( + repeaterAllowLocalEnv = "ARTEX_REPEATER_ALLOW_LOCAL" + repeaterMaxBody = 2 << 20 // 2 MiB + repeaterMaxReqBody = 2 << 20 + repeaterDefaultTimeout = 30_000 + repeaterMinTimeout = 1_000 + repeaterMaxTimeout = 120_000 +) + +var repeaterHopByHop = map[string]bool{ + "Connection": true, + "Keep-Alive": true, + "Proxy-Connection": true, + "Transfer-Encoding": true, + "Te": true, + "Trailer": true, + "Upgrade": true, + "Content-Length": true, // let Go compute from body + "Proxy-Authorization": true, +} + +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 req repeaterSendRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, repeaterMaxReqBody)).Decode(&req); err != nil { + writeErr(w, 400, "invalid body") + return + } + method := strings.ToUpper(strings.TrimSpace(req.Method)) + if method == "" { + method = http.MethodGet + } + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodHead, http.MethodOptions: + default: + writeErr(w, 400, "不支持的 HTTP 方法") + return + } + rawURL := strings.TrimSpace(req.URL) + if rawURL == "" { + writeErr(w, 400, "url 必填") + return + } + u, err := url.Parse(rawURL) + if err != nil || u.Scheme == "" || u.Host == "" { + writeErr(w, 400, "url 非法") + return + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + writeErr(w, 400, "仅支持 http/https") + return + } + if err := repeaterRejectSensitiveURL(u); err != nil { + writeErr(w, 400, err.Error()) + return + } + + timeoutMs := req.TimeoutMs + if timeoutMs <= 0 { + timeoutMs = repeaterDefaultTimeout + } + if timeoutMs < repeaterMinTimeout { + timeoutMs = repeaterMinTimeout + } + if timeoutMs > repeaterMaxTimeout { + timeoutMs = repeaterMaxTimeout + } + + useProxy := true + if req.UseRecordingProxy != nil { + useProxy = *req.UseRecordingProxy + } + + var bodyReader io.Reader + if req.Body != "" && method != http.MethodHead { + bodyReader = strings.NewReader(req.Body) + } + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + httpReq, err := http.NewRequestWithContext(ctx, method, u.String(), bodyReader) + if err != nil { + writeErr(w, 400, "构造请求失败: "+err.Error()) + return + } + for k, v := range req.Headers { + name := strings.TrimSpace(k) + if name == "" { + continue + } + if repeaterHopByHop[http.CanonicalHeaderKey(name)] { + continue + } + if strings.EqualFold(name, "Host") { + httpReq.Host = strings.TrimSpace(v) + continue + } + httpReq.Header.Set(name, v) + } + + client := &http.Client{ + Timeout: time.Duration(timeoutMs) * time.Millisecond, + Transport: s.repeaterTransport(useProxy, req.InsecureSkipVerify), + CheckRedirect: func(next *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("重定向次数过多") + } + if len(via) > 0 && 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(httpReq) + elapsed := time.Since(started).Milliseconds() + if err != nil { + writeJSON(w, 200, repeaterSendResponse{ + ElapsedMs: elapsed, + URL: u.String(), + Headers: map[string][]string{}, + Error: repeaterRedactErr(err), + }) + return + } + defer resp.Body.Close() + + limited := io.LimitReader(resp.Body, repeaterMaxBody+1) + raw, readErr := io.ReadAll(limited) + truncated := false + if len(raw) > repeaterMaxBody { + raw = raw[:repeaterMaxBody] + truncated = true + } + bodyStr := string(bytes.ToValidUTF8(raw, []byte("�"))) + out := repeaterSendResponse{ + Status: resp.StatusCode, + StatusText: resp.Status, + Proto: resp.Proto, + Headers: resp.Header.Clone(), + Body: bodyStr, + BodyTruncated: truncated, + BodyBytes: len(raw), + ElapsedMs: elapsed, + URL: resp.Request.URL.String(), + } + if readErr != nil && !errors.Is(readErr, io.EOF) { + out.Error = "读取响应失败: " + readErr.Error() + } + writeJSON(w, 200, out) +} + +// repeaterTransport builds the outbound Transport: recording/global proxy when +// requested. Dial-time SSRF guard is attached only on the DIRECT path — when a +// proxy is configured, DialContext reaches the proxy (often 127.0.0.1 MITM), so +// blocking loopback there would break Repeater entirely. Literal blocked IPs in +// the request URL are still rejected up-front in sendRepeater. +func (s *Server) repeaterTransport(useRecordingProxy, insecure bool) http.RoundTripper { + var base *http.Transport + viaProxy := false + if useRecordingProxy { + if tr := s.httpProxyTransport(httpExec{UseRecordingProxy: true}); tr != nil { + base = tr + viaProxy = true + } + } + if base == nil { + if gp := strings.TrimSpace(s.m.GlobalProxy()); gp != "" { + if tr := s.httpProxyTransport(httpExec{Proxy: gp}); tr != nil { + base = tr + viaProxy = true + } + } + } + if base == nil { + if dt, ok := http.DefaultTransport.(*http.Transport); ok { + base = dt.Clone() + } else { + base = &http.Transport{} + } + } else { + base = base.Clone() + } + if !viaProxy { + base.DialContext = (&net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + Control: repeaterBlockSensitiveDial, + }).DialContext + } + if insecure { + if base.TLSClientConfig == nil { + base.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // user-opted for pentest targets + } else { + cfg := base.TLSClientConfig.Clone() + cfg.InsecureSkipVerify = true + base.TLSClientConfig = cfg + } + } + return base +} + +// repeaterRejectSensitiveURL blocks literal loopback / link-local / metadata +// addresses in the request URL. Hostname targets are still covered at dial time +// on the direct path (DNS rebinding). When egress goes through a proxy, this +// precheck is the primary SSRF gate for literal IPs. +func repeaterRejectSensitiveURL(u *url.URL) error { + if repeaterAllowLocal() { + return nil + } + host := u.Hostname() + ip := net.ParseIP(host) + if ip == nil { + return nil + } + if repeaterIsBlockedDialIP(ip) { + return fmt.Errorf("拒绝访问本机/链路本地地址 %s(如确需访问本机服务,设置 %s=1)", ip, repeaterAllowLocalEnv) + } + return nil +} + +func repeaterAllowLocal() bool { + v := strings.TrimSpace(os.Getenv(repeaterAllowLocalEnv)) + return v == "1" || strings.EqualFold(v, "true") +} + +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() +} + +// repeaterBlockSensitiveDial is the dial-time SSRF guard for Repeater. +// See notify/http.go blockInternalDial for the shared rationale: stop the +// authenticated UI from using ARTEX as a trampoline onto loopback / cloud +// metadata, while still allowing RFC1918 private targets common in pentests. +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 repeaterRedactErr(err error) string { + var uerr *url.Error + if errors.As(err, &uerr) { + host := "" + if u, parseErr := url.Parse(uerr.URL); parseErr == nil { + host = u.Host + } + if uerr.Err != nil { + return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err) + } + return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host) + } + return err.Error() +} diff --git a/server/repeater_test.go b/server/repeater_test.go new file mode 100644 index 0000000..32aca43 --- /dev/null +++ b/server/repeater_test.go @@ -0,0 +1,133 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +func TestRepeaterIsBlockedDialIP(t *testing.T) { + blocked := []string{"127.0.0.1", "::1", "169.254.169.254", "169.254.1.1", "0.0.0.0", "224.0.0.1"} + for _, raw := range blocked { + if !repeaterIsBlockedDialIP(net.ParseIP(raw)) { + t.Errorf("expected %s blocked", raw) + } + } + allowed := []string{"8.8.8.8", "10.0.0.1", "192.168.1.1", "172.16.0.5"} + for _, raw := range allowed { + if repeaterIsBlockedDialIP(net.ParseIP(raw)) { + t.Errorf("expected %s allowed", raw) + } + } +} + +func TestSendRepeaterRejectsBadInput(t *testing.T) { + s := &Server{} + cases := []struct { + name string + body string + want int + }{ + {"missing url", `{"method":"GET"}`, 400}, + {"bad scheme", `{"method":"GET","url":"file:///etc/passwd"}`, 400}, + {"bad method", `{"method":"TRACE","url":"http://example.com"}`, 400}, + {"gopher", `{"method":"GET","url":"gopher://example.com/1"}`, 400}, + {"literal metadata", `{"method":"GET","url":"http://169.254.169.254/latest/meta-data/"}`, 400}, + {"literal loopback", `{"method":"GET","url":"http://127.0.0.1:9/"}`, 400}, + } + for _, tc := range cases { + 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 != tc.want { + t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String()) + } + }) + } +} + +func TestSendRepeaterRoundTripWithAllowLocal(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + var sawMethod, sawHeader, sawBody string + var sawHost string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawMethod = r.Method + sawHost = r.Host + sawHeader = r.Header.Get("X-Test") + b, _ := io.ReadAll(r.Body) + sawBody = string(b) + w.Header().Set("X-Echo", "ok") + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "hello-repeater") + })) + defer srv.Close() + + s := &Server{m: &Manager{}} + payload := map[string]any{ + "method": "POST", + "url": srv.URL + "/echo", + "headers": map[string]string{"X-Test": "v1", "Host": strings.TrimPrefix(srv.URL, "http://"), "Connection": "keep-alive", "Content-Length": "9999"}, + "body": "payload", + "use_recording_proxy": false, + } + raw, _ := json.Marshal(payload) + r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", bytes.NewReader(raw)) + w := httptest.NewRecorder() + s.sendRepeater(w, r) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var out repeaterSendResponse + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Error != "" { + t.Fatalf("unexpected error: %s", out.Error) + } + if out.Status != 200 || out.Body != "hello-repeater" { + t.Fatalf("bad response: %+v", out) + } + if sawMethod != "POST" || sawHeader != "v1" || sawBody != "payload" { + t.Fatalf("server saw method=%q header=%q body=%q", sawMethod, sawHeader, sawBody) + } + if sawHost == "" { + t.Fatal("expected Host to be set") + } + if out.Headers["X-Echo"] == nil || out.Headers["X-Echo"][0] != "ok" { + t.Fatalf("missing echo header: %+v", out.Headers) + } +} + +func TestSendRepeaterBlocksLoopbackByDefault(t *testing.T) { + _ = os.Unsetenv(repeaterAllowLocalEnv) + t.Setenv(repeaterAllowLocalEnv, "") + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler should not be reached") + })) + defer srv.Close() + + s := &Server{m: &Manager{}} + payload := map[string]any{ + "method": "GET", + "url": srv.URL, // httptest listens on 127.0.0.1 + "use_recording_proxy": false, + } + raw, _ := json.Marshal(payload) + r := httptest.NewRequest(http.MethodPost, "/api/repeater/send", bytes.NewReader(raw)) + w := httptest.NewRecorder() + s.sendRepeater(w, r) + // Literal loopback is rejected before dial. + if w.Code != 400 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "本机") && !strings.Contains(w.Body.String(), "拒绝") { + t.Fatalf("error should mention block: %s", w.Body.String()) + } +} 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..8f3fca0 --- /dev/null +++ b/web/src/components/finding-repeater-dialog.tsx @@ -0,0 +1,308 @@ +"use client"; + +import * as React from "react"; + +import { SendIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { HttpCodeBlock } from "@/components/http-code-block"; +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; + at: number; + method: string; + url: string; + headersText: string; + body: string; + response: RepeaterSendResponse; +}; + +export function parseHeadersText(text: string): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const sep = trimmed.indexOf(":"); + if (sep <= 0) continue; + const key = trimmed.slice(0, sep).trim(); + const value = trimmed.slice(sep + 1).trim(); + if (key) out[key] = value; + } + return out; +} + +export function formatHeaders(h: Record | Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(h)) { + if (Array.isArray(value)) { + for (const item of value) lines.push(`${key}: ${item}`); + } else { + lines.push(`${key}: ${value}`); + } + } + return lines.join("\n"); +} + +export function buildRawResponse(r: RepeaterSendResponse): string { + if (r.error && !r.status) { + return `错误: ${r.error}`; + } + const start = r.status_text?.trim() + ? `${r.proto || "HTTP/1.1"} ${r.status_text}` + : `${r.proto || "HTTP/1.1"} ${r.status}`; + const headers = formatHeaders(r.headers ?? {}); + return `${start}\n${headers}${headers ? "\n" : ""}\n${r.body ?? ""}`; +} + +/** Strip the request line from a captured req_head, keeping header lines. */ +export function headersFromReqHead(reqHead?: string): string { + if (!reqHead) return ""; + const lines = reqHead.replace(/\r\n/g, "\n").split("\n"); + if (lines.length === 0) return ""; + const first = lines[0] ?? ""; + const rest = /^[A-Z]+\s+\S+\s+HTTP\//i.test(first) ? lines.slice(1) : lines; + return rest.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 histSeq = React.useRef(0); + const seedKey = React.useRef(0); + + React.useEffect(() => { + if (!open) return; + seedKey.current += 1; + setMethod((initial?.method || "GET").toUpperCase()); + setUrl(initial?.url ?? ""); + setHeadersText(initial?.headersText ?? ""); + setBody(initial?.body ?? ""); + setResponse(null); + }, [open, initial]); + + async function send() { + const trimmedURL = url.trim(); + if (!trimmedURL) { + toast.error("请填写 URL"); + return; + } + setBusy(true); + try { + const result = await api.repeaterSend({ + method, + url: trimmedURL, + headers: parseHeadersText(headersText), + body, + use_recording_proxy: useProxy, + insecure_skip_verify: insecure, + }); + setResponse(result); + histSeq.current += 1; + setHistory((prev) => + [ + { + id: histSeq.current, + at: Date.now(), + method, + url: trimmedURL, + headersText, + body, + response: result, + }, + ...prev, + ].slice(0, 20), + ); + if (result.error && !result.status) { + toast.error(result.error); + } + } catch (e) { + toast.error((e as Error).message); + } finally { + setBusy(false); + } + } + + function loadHistory(entry: HistoryEntry) { + setMethod(entry.method); + setUrl(entry.url); + setHeadersText(entry.headersText); + setBody(entry.body); + setResponse(entry.response); + } + + return ( + + + + Repeater · 请求重放 + + 手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告 + {findingLabel ? ` · ${findingLabel}` : ""} + + + +
+
+ + +
+
+ + setUrl(e.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((h) => ( + + )) + )} +
+
+ +
+
请求
+
+
+ +