mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-07 12:23:49 +08:00
feat: 发现页新增类似 Burp Repeater 的请求重放功能 (#42)
在发现详情的关联流量区提供 Repeater:可从流量证据一键预填,或空白编辑 方法/URL/头/体后由服务端代发请求并展示原始响应,便于人工验证与报告撰写。 - POST /api/repeater/send:复用 recording/global 代理出口 - 直连路径拨号层拦截环回/链路本地/元数据;字面敏感 IP 在 URL 预检拒绝 - 会话内历史(前端内存);跨主机重定向拒绝
This commit is contained in:
1 parent
620ca5d96c
commit
341caa5e1f
8 files changed
+884
-7
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string[]> | Record<string, string>): 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<RepeaterSendResponse | null>(null);
|
||||
const [history, setHistory] = React.useState<HistoryEntry[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1100px,96vw)] max-w-none flex-col gap-3 overflow-hidden sm:max-w-none">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Repeater · 请求重放</DialogTitle>
|
||||
<DialogDescription>
|
||||
手工编辑并发送 HTTP 请求,便于验证漏洞与撰写报告
|
||||
{findingLabel ? ` · ${findingLabel}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="w-28">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">方法</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[240px] flex-1">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">URL</Label>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://target.example/path"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<Button disabled={busy} onClick={() => void send()}>
|
||||
<SendIcon data-icon="inline-start" />
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={useProxy} onCheckedChange={(v) => setUseProxy(v === true)} />
|
||||
经流量代理
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={insecure} onCheckedChange={(v) => setInsecure(v === true)} />
|
||||
跳过 TLS 校验
|
||||
</label>
|
||||
{response ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
|
||||
<span>{response.elapsed_ms} ms</span>
|
||||
<span>{response.body_bytes.toLocaleString()} 字节</span>
|
||||
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[180px_1fr_1fr]">
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border p-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">本次会话历史</div>
|
||||
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto">
|
||||
{history.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">发送后会出现在这里</p>
|
||||
) : (
|
||||
history.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => loadHistory(h)}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{h.method} {h.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{h.url}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">请求</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求头</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
value={headersText}
|
||||
onChange={(e) => setHeadersText(e.target.value)}
|
||||
placeholder={"Host: target.example\nContent-Type: application/json"}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">请求体</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px] flex-1 font-mono text-xs"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="请求正文(可选)"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-2 overflow-hidden">
|
||||
<div className="text-xs font-medium">响应</div>
|
||||
{response?.error ? (
|
||||
<Alert variant={response.status ? "default" : "destructive"}>
|
||||
<AlertDescription className="break-all font-mono text-xs">{response.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{response ? (
|
||||
<HttpCodeBlock raw={buildRawResponse(response)} />
|
||||
) : (
|
||||
<p className="p-3 text-xs text-muted-foreground">发送后显示原始响应</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
@@ -49,6 +50,9 @@ export function FindingTrafficPanel({
|
||||
const [error, setError] = React.useState("");
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial | undefined>();
|
||||
const [repeaterBusy, setRepeaterBusy] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
@@ -94,20 +98,52 @@ export function FindingTrafficPanel({
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
|
||||
function openBlankRepeater() {
|
||||
setRepeaterInitial({ method: "GET", url: "", headersText: "", body: "" });
|
||||
setRepeaterOpen(true);
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
const detail = await api.findingTrafficDetail(findingId, bindingId, contextTask);
|
||||
if (detail.request.truncated) {
|
||||
toast.info("请求体为预览片段,完整正文请先下载");
|
||||
}
|
||||
setRepeaterInitial({
|
||||
method: detail.binding.snapshot.method || "GET",
|
||||
url: detail.binding.snapshot.url || "",
|
||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||
body: detail.request.content || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setRepeaterBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>关联流量 {data ? `(${data.bindings.length})` : ""}</CardTitle>
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
绑定流量
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={openBlankRepeater}>
|
||||
打开 Repeater
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">继承证据 · 只读</Badge>
|
||||
)}
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
绑定流量
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">继承证据 · 只读</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>按复现顺序组织请求与响应。清理原始流量后,已绑定证据仍然保留。</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -159,6 +195,14 @@ export function FindingTrafficPanel({
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
查看报文
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={repeaterBusy}
|
||||
onClick={() => void seedRepeater(b.id)}
|
||||
>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -280,6 +324,12 @@ export function FindingTrafficPanel({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={`发现 #${findingId}`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRepeaterDialog, headersFromReqHead, type RepeaterInitial } from "@/components/finding-repeater-dialog";
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -27,6 +28,8 @@ export function TrafficEvidenceViewer({
|
||||
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [repeaterOpen, setRepeaterOpen] = React.useState(false);
|
||||
const [repeaterInitial, setRepeaterInitial] = React.useState<RepeaterInitial | undefined>();
|
||||
React.useEffect(() => {
|
||||
if (!bindingId) return;
|
||||
let active = true;
|
||||
@@ -63,6 +66,7 @@ export function TrafficEvidenceViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
@@ -76,6 +80,28 @@ export function TrafficEvidenceViewer({
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detail ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (detail.request.truncated) {
|
||||
toast.info("请求体为预览片段,完整正文请先下载");
|
||||
}
|
||||
setRepeaterInitial({
|
||||
method: detail.binding.snapshot.method || "GET",
|
||||
url: detail.binding.snapshot.url || "",
|
||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||
body: detail.request.content || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
}}
|
||||
>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
@@ -120,9 +146,17 @@ export function TrafficEvidenceViewer({
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={detail ? `证据 #${detail.binding.id}` : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
|
||||
@@ -51,6 +51,8 @@ import type {
|
||||
FindingsPage,
|
||||
FindingTraffic,
|
||||
FindingTrafficDetail,
|
||||
RepeaterSendRequest,
|
||||
RepeaterSendResponse,
|
||||
IntelligencePage,
|
||||
IntentAsset,
|
||||
InterceptApprovalFilter,
|
||||
@@ -699,6 +701,8 @@ export const api = {
|
||||
get<Finding>(
|
||||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||||
),
|
||||
repeaterSend: (req: RepeaterSendRequest) => post<RepeaterSendResponse>("/repeater/send", req),
|
||||
|
||||
findingTraffic: (id: string, contextTask?: string) =>
|
||||
get<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
|
||||
@@ -1618,6 +1618,28 @@ export interface FindingTrafficDetail {
|
||||
response: EvidenceBodyPreview;
|
||||
}
|
||||
|
||||
export interface RepeaterSendRequest {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeout_ms?: number;
|
||||
insecure_skip_verify?: boolean;
|
||||
use_recording_proxy?: boolean;
|
||||
}
|
||||
export interface RepeaterSendResponse {
|
||||
status: number;
|
||||
status_text: string;
|
||||
proto: string;
|
||||
headers: Record<string, string[]>;
|
||||
body: string;
|
||||
body_truncated: boolean;
|
||||
body_bytes: number;
|
||||
elapsed_ms: number;
|
||||
url: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSettings {
|
||||
proxy: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user