mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #46 from RuoJi6/feat/findings-repeater
feat: 发现页新增类似 Burp Repeater 的请求重放功能 (#42)
This commit is contained in:
10 files changed
+1356
-67
No files matched your search
+13
-3
@@ -462,9 +462,16 @@ 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 already a complete URL while capture is enabled, and
|
||||
// returns the configured global proxy URL when capture is off. Keep both
|
||||
// forms intact (including socks5:// and credentials).
|
||||
if s.m.TrafficEnabled() {
|
||||
if addr := s.m.ProxyAddr(); addr != "" {
|
||||
proxyStr = addr
|
||||
caFile = s.m.ProxyCACert()
|
||||
}
|
||||
} else if global := strings.TrimSpace(s.m.GlobalProxy()); global != "" {
|
||||
proxyStr = global
|
||||
}
|
||||
}
|
||||
if proxyStr == "" {
|
||||
@@ -474,6 +481,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 {
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"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
|
||||
},
|
||||
}
|
||||
// Each send owns its transport. Registered before Body.Close so idle
|
||||
// connections are released after the response body, including on errors.
|
||||
defer client.CloseIdleConnections()
|
||||
|
||||
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
|
||||
// Use the request's timeout_ms budget instead of inherited phase deadlines.
|
||||
transport.TLSHandshakeTimeout = 0
|
||||
transport.ExpectContinueTimeout = 0
|
||||
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
|
||||
// Let the request context enforce timeout_ms for the entire dial.
|
||||
transport.DialContext = (&net.Dialer{
|
||||
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 ip == nil && repeaterLooksNumericHost(host) {
|
||||
var ok bool
|
||||
ip, ok = repeaterParseLegacyIPv4(host)
|
||||
if !ok {
|
||||
return fmt.Errorf("拒绝无法解析的数字主机地址 %q", host)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") || (ip != nil && repeaterIsBlockedDialIP(ip)) {
|
||||
return fmt.Errorf("拒绝访问本机/链路本地地址(如确需访问本机服务,设置 %s=1)", repeaterAllowLocalEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repeaterLooksNumericHost identifies the legacy numeric spellings accepted by
|
||||
// common proxy/resolver implementations (decimal, octal, hexadecimal and
|
||||
// dotted inet_aton forms). A numeric-looking value that fails to parse is
|
||||
// rejected instead of being handed to a proxy for its own interpretation.
|
||||
func repeaterLooksNumericHost(host string) bool {
|
||||
if host == "" || host[0] < '0' || host[0] > '9' {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(host); i++ {
|
||||
c := host[i]
|
||||
switch {
|
||||
case c == '.':
|
||||
case c >= '0' && c <= '9':
|
||||
case c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||||
case c == 'x' || c == 'X':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// repeaterParseLegacyIPv4 follows inet_aton's 1-, 2-, 3- and 4-part IPv4
|
||||
// notation. Parts with a leading zero are octal and parts with 0x are hex.
|
||||
func repeaterParseLegacyIPv4(host string) (net.IP, bool) {
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 1 || len(parts) > 4 {
|
||||
return nil, false
|
||||
}
|
||||
values := make([]uint64, len(parts))
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return nil, false
|
||||
}
|
||||
base := 10
|
||||
digits := part
|
||||
if len(digits) > 2 && digits[0] == '0' && (digits[1] == 'x' || digits[1] == 'X') {
|
||||
base, digits = 16, digits[2:]
|
||||
} else if len(digits) > 1 && digits[0] == '0' {
|
||||
base = 8
|
||||
}
|
||||
if digits == "" {
|
||||
return nil, false
|
||||
}
|
||||
value, err := strconv.ParseUint(digits, base, 32)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
values[i] = value
|
||||
}
|
||||
var value uint64
|
||||
switch len(values) {
|
||||
case 1:
|
||||
value = values[0]
|
||||
case 2:
|
||||
if values[0] > 0xff || values[1] > 0xffffff {
|
||||
return nil, false
|
||||
}
|
||||
value = values[0]<<24 | values[1]
|
||||
case 3:
|
||||
if values[0] > 0xff || values[1] > 0xff || values[2] > 0xffff {
|
||||
return nil, false
|
||||
}
|
||||
value = values[0]<<24 | values[1]<<16 | values[2]
|
||||
case 4:
|
||||
for _, part := range values {
|
||||
if part > 0xff {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
value = values[0]<<24 | values[1]<<16 | values[2]<<8 | values[3]
|
||||
}
|
||||
if value > 0xffffffff {
|
||||
return nil, false
|
||||
}
|
||||
return net.IPv4(byte(value>>24), byte(value>>16), byte(value>>8), byte(value)), 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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/traffic"
|
||||
)
|
||||
|
||||
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 TestRepeaterRejectsLegacyNumericHostsIncludingProxyPath(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
for _, raw := range []string{
|
||||
"http://2130706433/", // decimal 127.0.0.1
|
||||
"http://0177.0.0.1/", // octal 127.0.0.1
|
||||
"http://127.1/", // two-part inet_aton form
|
||||
"http://0x7f000001/", // hexadecimal 127.0.0.1
|
||||
"http://9999999999/", // numeric-looking but invalid
|
||||
} {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repeaterCheckURL(u); err == nil {
|
||||
t.Errorf("repeaterCheckURL(%q) allowed a legacy numeric host", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The check must run before a configured proxy gets a chance to interpret
|
||||
// the legacy spelling and connect to loopback remotely.
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("proxy received a blocked numeric target")
|
||||
}))
|
||||
defer proxy.Close()
|
||||
s := &Server{m: &Manager{globalProxy: proxy.URL}}
|
||||
code, _, body := repeaterTestSend(t, s, map[string]any{"url": "http://2130706433/"})
|
||||
if code != http.StatusBadRequest || !strings.Contains(body, "拒绝") {
|
||||
t.Fatalf("proxied legacy host status=%d body=%s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterBlockSensitiveDial(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
for _, address := range []string{"127.0.0.1:80", "169.254.169.254:80"} {
|
||||
if err := repeaterBlockSensitiveDial("tcp", address, nil); err == nil {
|
||||
t.Errorf("repeaterBlockSensitiveDial(%q) allowed blocked address", address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterTransportBlocksSensitiveDial(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "")
|
||||
tr := (&Server{}).repeaterTransport(false, false).(*http.Transport)
|
||||
defer tr.CloseIdleConnections()
|
||||
if tr.DialContext == nil {
|
||||
t.Fatal("direct transport has no guarded DialContext")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
conn, err := tr.DialContext(ctx, "tcp", "127.0.0.1:1")
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "拒绝访问本机/链路本地地址 127.0.0.1") {
|
||||
t.Fatalf("dial should fail at the sensitive-address guard, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterDirectHTTP2(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ProtoMajor != 2 {
|
||||
t.Errorf("request protocol=%s, want HTTP/2", r.Proto)
|
||||
}
|
||||
_, _ = io.WriteString(w, "h2 response")
|
||||
}))
|
||||
srv.EnableHTTP2 = true
|
||||
srv.TLS = &tls.Config{NextProtos: []string{"h2"}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
|
||||
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true,
|
||||
})
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Proto != "HTTP/2.0" || out.Body != "h2 response" {
|
||||
t.Fatalf("HTTP/2 response status=%d raw=%s", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterTLSHandshakeUsesRequestTimeout(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
// Scale down the inherited timeout so a slow handshake can be tested
|
||||
// without waiting for the default transport's ten-second deadline.
|
||||
base := http.DefaultTransport
|
||||
short := base.(*http.Transport).Clone()
|
||||
short.TLSHandshakeTimeout = 10 * time.Millisecond
|
||||
http.DefaultTransport = short
|
||||
defer func() { http.DefaultTransport = base }()
|
||||
defer short.CloseIdleConnections()
|
||||
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "handshake completed")
|
||||
}))
|
||||
srv.TLS = &tls.Config{GetConfigForClient: func(*tls.ClientHelloInfo) (*tls.Config, error) {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return nil, nil
|
||||
}}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
code, out, raw := repeaterTestSend(t, &Server{}, map[string]any{
|
||||
"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true, "timeout_ms": 1000,
|
||||
})
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "handshake completed" {
|
||||
t.Fatalf("handshake within request budget failed: status=%d raw=%s", code, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterClosesIdleConnections(t *testing.T) {
|
||||
t.Setenv(repeaterAllowLocalEnv, "1")
|
||||
for _, mode := range []string{"direct", "http2", "global proxy", "recording proxy fallback"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
closed := make(chan struct{}, 1)
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "response")
|
||||
}))
|
||||
srv.Config.ConnState = func(_ net.Conn, state http.ConnState) {
|
||||
if state == http.StateClosed {
|
||||
closed <- struct{}{}
|
||||
}
|
||||
}
|
||||
if mode == "http2" {
|
||||
srv.EnableHTTP2 = true
|
||||
srv.StartTLS()
|
||||
} else {
|
||||
srv.Start()
|
||||
}
|
||||
defer srv.Close()
|
||||
|
||||
s := &Server{}
|
||||
payload := map[string]any{"url": srv.URL, "use_recording_proxy": false, "insecure_skip_verify": true}
|
||||
if mode == "global proxy" || mode == "recording proxy fallback" {
|
||||
s.m = &Manager{globalProxy: srv.URL}
|
||||
payload["url"] = "http://target.example/"
|
||||
if mode == "recording proxy fallback" {
|
||||
delete(payload, "use_recording_proxy")
|
||||
}
|
||||
}
|
||||
code, out, raw := repeaterTestSend(t, s, payload)
|
||||
if code != http.StatusOK || out.Error != "" || out.Status != http.StatusOK || out.Body != "response" {
|
||||
t.Fatalf("status=%d raw=%s", code, raw)
|
||||
}
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("connection still open after sendRepeater returned")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 TestRepeaterProxyTransportConstruction(t *testing.T) {
|
||||
s := &Server{m: &Manager{}}
|
||||
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
|
||||
tr := s.httpProxyTransport(httpExec{Proxy: scheme + "://user:[email protected]:1080"})
|
||||
if tr == nil || tr.Proxy == nil {
|
||||
t.Fatalf("%s proxy transport not configured: %#v", scheme, tr)
|
||||
}
|
||||
proxyURL, err := tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
|
||||
if err != nil || proxyURL == nil || proxyURL.Scheme != scheme || proxyURL.Host != "127.0.0.1:1080" {
|
||||
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:[email protected]: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)
|
||||
}
|
||||
|
||||
// Capture mode supplies a complete HTTP URL from Traffic.ProxyAddr. The
|
||||
// helper must preserve that scheme and host rather than prefixing http:// a
|
||||
// second time.
|
||||
captured, err := traffic.Open(t.TempDir(), "127.0.0.1:18788")
|
||||
if err != nil {
|
||||
t.Fatalf("traffic.Open: %v", err)
|
||||
}
|
||||
defer captured.Close()
|
||||
s.m.traffic = captured
|
||||
s.m.trafficOn = true
|
||||
tr = s.httpProxyTransport(httpExec{UseRecordingProxy: true})
|
||||
if tr == nil || tr.Proxy == nil {
|
||||
t.Fatal("recording proxy was not configured")
|
||||
}
|
||||
proxyURL, err = tr.Proxy(httptest.NewRequest(http.MethodGet, "https://target.example/", nil))
|
||||
if err != nil || proxyURL == nil || proxyURL.Scheme != "http" || proxyURL.Host != "127.0.0.1:18788" {
|
||||
t.Fatalf("recording proxy URL = %#v, err=%v", proxyURL, err)
|
||||
}
|
||||
caFile := s.m.ProxyCACert()
|
||||
if caFile == "" {
|
||||
t.Fatal("recording proxy CA path is empty")
|
||||
}
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read recording proxy CA: %v", err)
|
||||
}
|
||||
wantRoots := x509.NewCertPool()
|
||||
if !wantRoots.AppendCertsFromPEM(caPEM) {
|
||||
t.Fatal("recording proxy CA is not valid PEM")
|
||||
}
|
||||
if tr.TLSClientConfig == nil || tr.TLSClientConfig.RootCAs == nil || !tr.TLSClientConfig.RootCAs.Equal(wantRoots) {
|
||||
t.Fatal("recording proxy CA was not loaded into transport RootCAs")
|
||||
}
|
||||
if s.repeaterTransport(true, false).(*http.Transport).ForceAttemptHTTP2 {
|
||||
t.Fatal("recording proxy transport must not force HTTP/2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeaterRedactProxyCredentials(t *testing.T) {
|
||||
for _, scheme := range []string{"http", "https", "socks5", "socks5h"} {
|
||||
t.Run(scheme, func(t *testing.T) {
|
||||
proxyErr := &url.Error{
|
||||
Op: "proxyconnect", URL: scheme + "://proxy-user:[email protected]:1080",
|
||||
Err: errors.New("connection refused"),
|
||||
}
|
||||
for _, err := range []error{proxyErr, fmt.Errorf("send request: %w", proxyErr)} {
|
||||
got := repeaterRedactError(err)
|
||||
if strings.Contains(got, "proxy-secret") || strings.Contains(got, "proxy-user") {
|
||||
t.Fatalf("proxy credentials leaked: %q", got)
|
||||
}
|
||||
if got != "proxyconnect 127.0.0.1:1080: connection refused" {
|
||||
t.Fatalf("unexpected redacted error: %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,336 @@
|
||||
"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<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
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<string, string[]> | Record<string, string>): 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<RepeaterSendResponse | null>(null);
|
||||
const [history, setHistory] = React.useState<HistoryEntry[]>([]);
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
// Invalidate immediately, before the parent's close render/effect.
|
||||
generation.current += 1;
|
||||
sending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex max-h-[92vh] w-[min(1150px,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} disabled={busy}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{METHODS.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item}
|
||||
</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
|
||||
disabled={busy}
|
||||
value={url}
|
||||
onChange={(event) => setURL(event.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" htmlFor="repeater-use-proxy">
|
||||
<Checkbox
|
||||
id="repeater-use-proxy"
|
||||
disabled={busy}
|
||||
checked={useProxy}
|
||||
onCheckedChange={(value) => setUseProxy(value === true)}
|
||||
/>
|
||||
经流量代理
|
||||
</Label>
|
||||
<Label className="flex items-center gap-2" htmlFor="repeater-insecure">
|
||||
<Checkbox
|
||||
id="repeater-insecure"
|
||||
disabled={busy}
|
||||
checked={insecure}
|
||||
onCheckedChange={(value) => setInsecure(value === true)}
|
||||
/>
|
||||
跳过 TLS 校验
|
||||
</Label>
|
||||
{response ? (
|
||||
<div className="flex 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((entry) => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
className="block w-full rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
onClick={() => {
|
||||
setMethod(entry.method);
|
||||
setURL(entry.url);
|
||||
setHeadersText(entry.headersText);
|
||||
setBody(entry.body);
|
||||
setUseProxy(entry.useProxy);
|
||||
setInsecure(entry.insecure);
|
||||
setResponse(entry.response);
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-mono text-xs">
|
||||
{entry.method} {entry.response.status || "ERR"}
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{entry.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"
|
||||
disabled={busy}
|
||||
value={headersText}
|
||||
onChange={(event) => setHeadersText(event.target.value)}
|
||||
placeholder="Header: value"
|
||||
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"
|
||||
disabled={busy}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.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 ? (
|
||||
<pre className="whitespace-pre-wrap break-words p-3 font-mono text-xs">
|
||||
{buildRawResponse(response)}
|
||||
</pre>
|
||||
) : (
|
||||
<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>();
|
||||
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));
|
||||
}
|
||||
|
||||
async function seedRepeater(bindingId: string) {
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
const detail = await api.findingTrafficDetail(findingId, bindingId, contextTask);
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else 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.binary ? "" : 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={() => {
|
||||
setRepeaterInitial({ method: "GET", url: "", headersText: "", body: "" });
|
||||
setRepeaterOpen(true);
|
||||
}}
|
||||
>
|
||||
打开 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,9 @@ 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
|
||||
@@ -228,6 +267,12 @@ export function FindingTrafficPanel({
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={`发现 #${findingId}`}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -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>();
|
||||
React.useEffect(() => {
|
||||
if (!bindingId) return;
|
||||
let active = true;
|
||||
@@ -62,64 +65,94 @@ export function TrafficEvidenceViewer({
|
||||
}
|
||||
}
|
||||
|
||||
function openRepeater() {
|
||||
if (!detail) return;
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else 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.binary ? "" : detail.request.content || "",
|
||||
});
|
||||
setRepeaterOpen(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<>
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 pe-8">
|
||||
<div>
|
||||
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{detail ? (
|
||||
<Button variant="outline" size="sm" onClick={openRepeater}>
|
||||
送入 Repeater
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">请求 Request</TabsTrigger>
|
||||
<TabsTrigger value="response">响应 Response</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
下载完整{side === "request" ? "请求" : "响应"}正文
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
加载更多正文
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FindingRepeaterDialog
|
||||
open={repeaterOpen}
|
||||
onOpenChange={setRepeaterOpen}
|
||||
initial={repeaterInitial}
|
||||
findingLabel={bindingId ? `证据 #${bindingId}` : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ import type {
|
||||
FindingsPage,
|
||||
FindingTraffic,
|
||||
FindingTrafficDetail,
|
||||
RepeaterSendRequest,
|
||||
RepeaterSendResponse,
|
||||
IntelligencePage,
|
||||
IntentAsset,
|
||||
InterceptApprovalFilter,
|
||||
@@ -733,6 +735,7 @@ export const api = {
|
||||
get<FindingTrafficDetail>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
repeaterSend: (req: RepeaterSendRequest) => post<RepeaterSendResponse>("/repeater/send", req),
|
||||
findingTrafficBody: (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
|
||||
@@ -1012,6 +1012,24 @@ export async function mockHandle<T>(method: string, rawPath: string, body?: Body
|
||||
function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record<string, unknown>): unknown {
|
||||
const task = q.get("task") ?? undefined;
|
||||
|
||||
// The demo client still expects the complete repeater response shape. No
|
||||
// network request is made in mock mode, but returning the shape keeps the
|
||||
// dialog's status/body counters safe for every method.
|
||||
if (path === "/repeater/send" && m === "POST") {
|
||||
const responseBody = "(演示模式)未发送真实请求。";
|
||||
return {
|
||||
status: 200,
|
||||
status_text: "200 OK",
|
||||
proto: "HTTP/1.1",
|
||||
headers: { "Content-Type": ["text/plain; charset=utf-8"] },
|
||||
body: responseBody,
|
||||
body_truncated: false,
|
||||
body_bytes: new TextEncoder().encode(responseBody).length,
|
||||
elapsed_ms: 0,
|
||||
url: String(b.url ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
if (path === "/chat/mentions" && m === "GET") {
|
||||
const kind = q.get("kind") ?? "";
|
||||
const query = (q.get("q") ?? "").trim().toLowerCase();
|
||||
|
||||
@@ -1618,6 +1618,29 @@ 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