diff --git a/server/repeater.go b/server/repeater.go index 3f157d9..435f53f 100644 --- a/server/repeater.go +++ b/server/repeater.go @@ -32,6 +32,7 @@ var repeaterHopByHop = map[string]struct{}{ } type repeaterSendRequest struct { + HTTPVersion string `json:"http_version"` Method *string `json:"method"` URL string `json:"url"` Headers map[string]string `json:"headers"` @@ -89,6 +90,13 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { return } + switch in.HTTPVersion { + case "", "HTTP/1.1", "HTTP/2.0": + default: + writeErr(w, http.StatusBadRequest, "当前支持 HTTP/1.1 和 HTTP/2.0") + return + } + rawURL := strings.TrimSpace(in.URL) if rawURL == "" { writeErr(w, http.StatusBadRequest, "url 必填") @@ -151,9 +159,30 @@ func (s *Server) sendRepeater(w http.ResponseWriter, r *http.Request) { req.Header.Set(name, value) } + transport := s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify) + if in.HTTPVersion != "" { + tr := transport.(*http.Transport) + // Keep TLS ALPN consistent with the explicit protocol, even when an + // inherited transport previously advertised h2. + if tr.TLSClientConfig == nil { + tr.TLSClientConfig = &tls.Config{} + } else { + tr.TLSClientConfig = tr.TLSClientConfig.Clone() + } + tr.TLSClientConfig.NextProtos = []string{"http/1.1"} + if in.HTTPVersion == "HTTP/2.0" { + tr.TLSClientConfig.NextProtos = []string{"h2"} + } + tr.ForceAttemptHTTP2 = in.HTTPVersion == "HTTP/2.0" + tr.Protocols = new(http.Protocols) + tr.Protocols.SetHTTP1(in.HTTPVersion == "HTTP/1.1") + tr.Protocols.SetHTTP2(in.HTTPVersion == "HTTP/2.0") + tr.Protocols.SetUnencryptedHTTP2(in.HTTPVersion == "HTTP/2.0" && strings.EqualFold(u.Scheme, "http")) + } + client := &http.Client{ Timeout: time.Duration(timeoutMs) * time.Millisecond, - Transport: s.repeaterTransport(useRecordingProxy, in.InsecureSkipVerify), + Transport: transport, CheckRedirect: func(next *http.Request, via []*http.Request) error { if len(via) >= 10 { return errors.New("重定向次数过多") diff --git a/server/repeater_test.go b/server/repeater_test.go index 608f04d..b721e62 100644 --- a/server/repeater_test.go +++ b/server/repeater_test.go @@ -402,3 +402,41 @@ func TestRepeaterRedactProxyCredentials(t *testing.T) { }) } } + +func TestRepeaterHonorsExplicitHTTPVersion(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Proto) + })) + srv.EnableHTTP2 = true + srv.StartTLS() + defer srv.Close() + for _, version := range []string{"HTTP/1.1", "HTTP/2.0"} { + t.Run(version, func(t *testing.T) { + code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{ + "method": "GET", "url": srv.URL, "http_version": version, + "insecure_skip_verify": true, "use_recording_proxy": false, + }) + if code != http.StatusOK || out.Error != "" || out.Body != version { + t.Fatalf("version %s: status=%d response=%s", version, code, raw) + } + }) + } + code, _, _ := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/1.0"}) + if code != http.StatusBadRequest { + t.Fatalf("unsupported version status=%d", code) + } +} + +func TestRepeaterExplicitHTTP2Cleartext(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.Proto) })) + srv.Config.Protocols = new(http.Protocols) + srv.Config.Protocols.SetUnencryptedHTTP2(true) + srv.Start() + defer srv.Close() + code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{"method": "GET", "url": srv.URL, "http_version": "HTTP/2.0", "use_recording_proxy": false}) + if code != http.StatusOK || out.Error != "" || out.Body != "HTTP/2.0" { + t.Fatalf("status=%d response=%s", code, raw) + } +} diff --git a/web/src/app/(main)/function/repeater/page.tsx b/web/src/app/(main)/function/repeater/page.tsx new file mode 100644 index 0000000..97867eb --- /dev/null +++ b/web/src/app/(main)/function/repeater/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { RepeaterWorkspace } from "@/components/repeater/repeater-workspace"; + +export default function RepeaterPage() { + return ; +} diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 18e5a78..f80910b 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -2,6 +2,8 @@ import * as React from "react"; +import { useRouter } from "next/navigation"; + import { ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, @@ -19,6 +21,8 @@ import { toast } from "sonner"; import { HttpCodeBlock } from "@/components/http-code-block"; import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; +import { requestFromTraffic } from "@/components/repeater/repeater-http"; +import { seedFromEvidence } from "@/components/repeater/repeater-store"; import { AlertDialog, AlertDialogAction, @@ -112,7 +116,10 @@ const SORT_STORAGE_KEY = "traffic-sort"; const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; export default function TrafficPage() { - const [selectedFlows, setSelectedFlows] = React.useState>(() => new Set()); + const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); + const router = useRouter(); + const [importing, setImporting] = React.useState(false); + const importLock = React.useRef(false); const [linking, setLinking] = React.useState(false); const [page, setPage] = React.useState(0); const [size, setSize] = React.useState(50); @@ -322,6 +329,38 @@ export default function TrafficPage() { }; }, [selected]); + async function openRepeater(flows: TrafficExchange[]) { + if (!flows.length || importLock.current) return; + importLock.current = true; + setImporting(true); + let imported = 0; + try { + // Sequential loading limits pressure when a whole page is selected. A bad + // capture does not discard drafts imported successfully before it. + for (const flow of flows) { + try { + const raw = await api.trafficExchange(flow.id); + seedFromEvidence( + requestFromTraffic(raw.req, flow), + { label: `流量 ${flow.id}` }, + { title: `${flow.method} ${flow.host}` }, + ); + imported += 1; + } catch (error) { + toast.error(`${flow.host}:${(error as Error).message}`); + } + } + if (imported) { + toast.success(`已送入 ${imported} 条请求,可编辑后发送`); + setSelected(null); + router.push("/function/repeater"); + } + } finally { + importLock.current = false; + setImporting(false); + } + } + // Toggle direction when re-clicking the active column, else sort the new column // newest/largest-first. const toggleSort = (field: SortField) => @@ -607,8 +646,16 @@ export default function TrafficPage() { + {selectedFlows.size > 0 ? ( - ) : null} @@ -626,9 +673,9 @@ export default function TrafficPage() { checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))} onCheckedChange={(checked) => setSelectedFlows((previous) => { - const next = new Set(previous); + const next = new Map(previous); for (const e of exchanges) { - if (checked === true) next.add(e.id); + if (checked === true) next.set(e.id, e); else next.delete(e.id); } return next; @@ -681,8 +728,8 @@ export default function TrafficPage() { onClick={(event) => event.stopPropagation()} onCheckedChange={(checked) => setSelectedFlows((previous) => { - const next = new Set(previous); - if (checked === true) next.add(e.id); + const next = new Map(previous); + if (checked === true) next.set(e.id, e); else next.delete(e.id); return next; }) @@ -721,9 +768,9 @@ export default function TrafficPage() { {linking ? ( setLinking(false)} - onBound={() => setSelectedFlows(new Set())} + onBound={() => setSelectedFlows(new Map())} /> ) : null} !open && setSelected(null)}> @@ -740,6 +787,15 @@ export default function TrafficPage() { {selected.host} {selected.url} + diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx deleted file mode 100644 index 56078a6..0000000 --- a/web/src/components/finding-repeater-dialog.tsx +++ /dev/null @@ -1,336 +0,0 @@ -"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. Cookies use semicolon separators; - // other duplicate headers preserve the common comma-separated semantics. - const existingName = Object.keys(result).find((item) => item.toLowerCase() === name.toLowerCase()); - if (!existingName) { - result[name] = value; - } else { - const separator = existingName.toLowerCase() === "cookie" ? "; " : ", "; - result[existingName] = `${result[existingName]}${separator}${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) return ""; - 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); - 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, - url: target, - headers: parseHeadersText(headersText), - body, - use_recording_proxy: useProxy, - insecure_skip_verify: insecure, - }); - if (requestGeneration !== generation.current) return; - setResponse(result); - const id = ++sequence.current; - setHistory((previous) => - [ - { id, method, url: target, headersText, body, useProxy, insecure, response: result }, - ...previous, - ].slice(0, 20), - ); - } catch (error) { - if (requestGeneration === generation.current) toast.error((error as Error).message); - } finally { - 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 · 请求重放 - - 手工编辑并发送 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) => ( - - )) - )} -
-
-
-
请求
-
-
- -