From ddfdc367876227d866f02d06f6e25be9f01aacf3 Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Sat, 24 Oct 2026 22:46:37 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=20Grok=20=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Repeater=20=E5=BD=95=E5=88=B6=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=9C=B0=E5=9D=80=E3=80=81=E6=95=B0=E5=AD=97=E4=B8=BB?= =?UTF-8?q?=E6=9C=BA=20SSRF=E3=80=81Cookie=20=E5=90=88=E5=B9=B6=E7=AD=89?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=88Codex=20gpt-6.1-sol=EF=BC=89(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/customtool.go | 9 +- server/repeater.go | 86 +++++++++++++++++++ server/repeater_test.go | 62 +++++++++++++ .../components/finding-repeater-dialog.tsx | 13 ++- web/src/lib/mock/handler.ts | 18 ++++ 5 files changed, 180 insertions(+), 8 deletions(-) diff --git a/server/customtool.go b/server/customtool.go index bfd76bc..ad68517 100644 --- a/server/customtool.go +++ b/server/customtool.go @@ -462,13 +462,12 @@ func (s *Server) httpProxyTransport(spec httpExec) *http.Transport { proxyStr := strings.TrimSpace(spec.Proxy) var caFile string if spec.UseRecordingProxy { - // ProxyAddr is the local MITM address while capture is enabled, but - // returns the configured global proxy URL when capture is off. Keep the - // latter as a complete URL (including socks5:// and credentials) instead - // of prepending http:// to it. + // 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 = "http://" + addr + proxyStr = addr caFile = s.m.ProxyCACert() } } else if global := strings.TrimSpace(s.m.GlobalProxy()); global != "" { diff --git a/server/repeater.go b/server/repeater.go index 9a9b550..87d0c80 100644 --- a/server/repeater.go +++ b/server/repeater.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "syscall" "time" @@ -272,12 +273,97 @@ func repeaterCheckURL(u *url.URL) error { 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 diff --git a/server/repeater_test.go b/server/repeater_test.go index 508a7e2..fe8ad1d 100644 --- a/server/repeater_test.go +++ b/server/repeater_test.go @@ -7,8 +7,11 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "strings" "testing" + + "github.com/Autumn-27/artex/traffic" ) func repeaterTestSend(t *testing.T, s *Server, payload any) (int, repeaterSendResponse, string) { @@ -72,6 +75,46 @@ func TestRepeaterBlockedIPs(t *testing.T) { } } +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 TestRepeaterRoundTripHeadersAndBody(t *testing.T) { t.Setenv(repeaterAllowLocalEnv, "1") var got http.Request @@ -183,4 +226,23 @@ func TestRepeaterSocks5TransportConstruction(t *testing.T) { 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) + } } diff --git a/web/src/components/finding-repeater-dialog.tsx b/web/src/components/finding-repeater-dialog.tsx index d609c2c..f925e23 100644 --- a/web/src/components/finding-repeater-dialog.tsx +++ b/web/src/components/finding-repeater-dialog.tsx @@ -45,9 +45,15 @@ export function parseHeadersText(text: string): Record { const name = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); if (!name) continue; - // The API accepts one value per name. Combining duplicates preserves the - // common comma-separated-header semantics for this MVP. - result[name] = result[name] ? `${result[name]}, ${value}` : value; + // 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; } @@ -224,6 +230,7 @@ export function FindingRepeaterDialog({