diff --git a/server/egress_probe.go b/server/egress_probe.go index 2145bab..70e764b 100644 --- a/server/egress_probe.go +++ b/server/egress_probe.go @@ -18,7 +18,9 @@ import ( "net/http" "net/url" "os" + "regexp" "strings" + "syscall" "time" "github.com/Autumn-27/artex/traffic" @@ -30,6 +32,96 @@ var defaultEgressProbes = []string{ "https://ifconfig.me/ip", // 返回纯文本 1.2.3.4 } +// validateProbeURL 只放行指向"非本机、非链路本地"的绝对 http(s) 地址。 +// +// 这个接口要登录,但仍要挡:agent 可能被目标页面诱导去调它,而"本机"在容器里就是 ARTEX +// 自己(管理端口、云元数据 169.254.169.254)。**私网地址是刻意放行的**——自定义探测服务 +// 的主用途就是内网回显,挡掉 192.168/10/172.16 等于废掉这个功能。 +func validateProbeURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err != nil { + return "", errors.New("probe_url 不是合法 URL") + } + if !strings.EqualFold(u.Scheme, "http") && !strings.EqualFold(u.Scheme, "https") { + return "", errors.New("probe_url 只支持 http / https") + } + if u.Host == "" || u.Hostname() == "" { + return "", errors.New("probe_url 缺少主机名") + } + if err := probeCheckURL(u); err != nil { + return "", err + } + return raw, nil +} + +// probeCheckRedirect 逐跳校验,挡住"公网地址 302 到 127.0.0.1"这种绕法。 +func probeCheckRedirect(next *http.Request, via []*http.Request) error { + if len(via) >= 3 { + 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 probeCheckURL(next.URL) +} + +// probeCheckURL rejects only loopback, link-local, unspecified, and multicast +// destinations. RFC1918 addresses remain valid for intranet echo services. +func probeCheckURL(u *url.URL) error { + host := strings.TrimSuffix(strings.TrimSpace(u.Hostname()), ".") + 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 && probeIsBlockedDialIP(ip)) { + return errors.New("拒绝访问本机/链路本地地址") + } + return nil +} + +func probeIsBlockedDialIP(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() +} + +// probeBlockSensitiveDial is always enabled for direct probes. Unlike the +// repeater, probe_url has no local-target escape hatch. +func probeBlockSensitiveDial(_, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return err + } + ip := net.ParseIP(host) + if ip == nil { + return fmt.Errorf("无法解析目标地址 %q", host) + } + if probeIsBlockedDialIP(ip) { + return fmt.Errorf("拒绝访问本机/链路本地地址 %s", ip) + } + return nil +} + +// userinfoRe 匹配 URL 里的 user:pass@。 +var userinfoRe = regexp.MustCompile(`://[^/@\s]+@`) + +// maskSecrets 去掉 URL 里的用户名密码。它们会进 Configured / Path / probes[].error, +// 而这三处都会被界面渲染或写进日志。 +func maskSecrets(s string) string { return userinfoRe.ReplaceAllString(s, "://***@") } + // egressAttempt 保留每个地址的失败原因,才能区分"连不上代理"和"代理通但出不了网"。 type egressAttempt struct { URL string `json:"url"` @@ -81,7 +173,7 @@ func probeEgress(ctx context.Context, proxyRaw, caPEMPath string, probes []strin if err != nil { return egressProbeResult{ OK: false, - Error: err.Error(), + Error: maskSecrets(err.Error()), LatencyMS: time.Since(start).Milliseconds(), // 空数组不是 nil:nil 会序列化成 JSON null,前端按数组处理会炸。 Probes: []egressAttempt{}, @@ -95,7 +187,8 @@ func probeEgress(ctx context.Context, proxyRaw, caPEMPath string, probes []strin client := &http.Client{ Transport: tr, // 比 ctx 宽 2 秒,让 ctx 先到期,错误里才是 "context deadline exceeded"。 - Timeout: timeout + 2*time.Second, + Timeout: timeout + 2*time.Second, + CheckRedirect: probeCheckRedirect, } attempts := make([]egressAttempt, 0, len(probes)) @@ -105,9 +198,9 @@ func probeEgress(ctx context.Context, proxyRaw, caPEMPath string, probes []strin ip, err := probeOne(reqCtx, client, target) cancel() - a := egressAttempt{URL: target, LatencyMS: time.Since(t0).Milliseconds()} + a := egressAttempt{URL: maskSecrets(target), LatencyMS: time.Since(t0).Milliseconds()} if err != nil { - a.Error = err.Error() + a.Error = maskSecrets(err.Error()) // 只在证书类错误上补一句,否则连不上代理也会挂上不相干的 CA 说明。 // 必须写进 probes[].error——界面渲染的是它,不是 result.error。 if caErr != nil && strings.Contains(a.Error, "certificate") { @@ -166,6 +259,10 @@ func buildProbeTransport(proxyURL *url.URL, caPEMPath string) (*http.Transport, } if proxyURL != nil { tr.Proxy = http.ProxyURL(proxyURL) + } else { + // 直连时在拨号阶段再查一次:挡住"域名解析后才变成 127.0.0.1"(DNS rebinding)。 + // 走代理时不装——那时拨的是代理地址,用户配 127.0.0.1 的本地代理是允许的。 + tr.DialContext = (&net.Dialer{Timeout: 10 * time.Second, Control: probeBlockSensitiveDial}).DialContext } // CA 不可用不 panic,把原因带回去拼进失败原因——否则只会看到 x509,看不出真实原因。 if path := strings.TrimSpace(caPEMPath); path != "" { @@ -245,6 +342,8 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { GlobalProxy string `json:"global_proxy"` ProbeURL string `json:"probe_url"` // 可选:覆盖默认探测地址 } + // 请求体只有一个地址,给个上限,免得被塞大 payload。 + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) // 空 body 等价于测直连。 if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { writeErr(w, 400, err.Error()) @@ -253,7 +352,12 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { probes := defaultEgressProbes if u := strings.TrimSpace(req.ProbeURL); u != "" { - probes = []string{u} + checked, err := validateProbeURL(u) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + probes = []string{checked} } // 这三个调用决定 agent 到底会走哪条路。 @@ -270,10 +374,11 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) defer cancel() + configuredProxy := strings.TrimSpace(req.GlobalProxy) rep := egressReport{ Capture: capture, - Configured: strings.TrimSpace(req.GlobalProxy), - AgentProxy: agentProxy, + Configured: maskSecrets(configuredProxy), + AgentProxy: maskSecrets(agentProxy), } rep.Checks = append(rep.Checks, egressCheck{ @@ -282,8 +387,8 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { Result: probeEgress(ctx, "", "", probes, 10*time.Second), }) - if cfg := rep.Configured; cfg != "" { - probeAddr, probeCA, viaMITM := egressTarget(cfg, capture, pending, agentProxy, agentCA) + if configuredProxy != "" { + probeAddr, probeCA, viaMITM := egressTarget(configuredProxy, capture, pending, agentProxy, agentCA) timeout := 10 * time.Second if viaMITM { timeout = 15 * time.Second // 多了一跳 TLS 解密 + 重签 @@ -292,7 +397,7 @@ func (s *Server) testEgressProxy(w http.ResponseWriter, r *http.Request) { Key: "global", Label: "② 经全局代理的出口 IP", // Path 是实际探测地址(经 MITM 时是 8788),排查时看这个。 - Path: probeAddr, + Path: maskSecrets(probeAddr), Result: probeEgress(ctx, probeAddr, probeCA, probes, timeout), }) } else { @@ -338,17 +443,18 @@ func judgeEgress(rep egressReport) bool { // egressWarnings 生成"看数字容易忽略"的提醒。 func egressWarnings(rep egressReport, pending bool, savedProxy string) []string { var out []string + safeSavedProxy := maskSecrets(savedProxy) // W1 agent 走的是已保存配置,不说清会被当成"测通了=配好了"。 if pending { switch { case rep.Configured == "": - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+savedProxy+"。保存后即改为直连。") + out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+"。保存后即改为直连。") case savedProxy == "": out = append(out, "此地址尚未保存,Agent 当前仍为直连。"+ "上方结果对应输入框中的值,保存后生效。") default: - out = append(out, "此地址尚未保存,Agent 当前仍使用 "+savedProxy+ + out = append(out, "此地址尚未保存,Agent 当前仍使用 "+safeSavedProxy+ "。上方结果对应输入框中的值,保存后生效。") } } diff --git a/server/egress_probe_test.go b/server/egress_probe_test.go index b802056..f7aed78 100644 --- a/server/egress_probe_test.go +++ b/server/egress_probe_test.go @@ -6,6 +6,9 @@ package server import ( "context" "encoding/pem" + "fmt" + "io" + "net" "net/http" "net/http/httptest" "os" @@ -39,6 +42,21 @@ func TestProbeEgress(t *testing.T) { defer deniedSrv.Close() fallbackSrv := fakeIPServer("203.0.113.42") defer fallbackSrv.Close() + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Host { + case strings.TrimPrefix(okSrv.URL, "http://"): + _, _ = w.Write([]byte(`{"ip":"203.0.113.7"}`)) + case strings.TrimPrefix(htmlSrv.URL, "http://"): + _, _ = w.Write([]byte("
access denied")) + case strings.TrimPrefix(deniedSrv.URL, "http://"): + http.Error(w, "nope", http.StatusForbidden) + case strings.TrimPrefix(fallbackSrv.URL, "http://"): + _, _ = w.Write([]byte(`{"ip":"203.0.113.42"}`)) + default: + http.Error(w, "unknown target", http.StatusBadGateway) + } + })) + defer proxy.Close() cases := []struct { name string @@ -48,10 +66,10 @@ func TestProbeEgress(t *testing.T) { check func(t *testing.T, res egressProbeResult) }{ { - name: "直连拿到 IP", proxy: "", probes: []string{okSrv.URL}, timeout: 5 * time.Second, + name: "经代理拿到 IP", proxy: proxy.URL, probes: []string{okSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { - if !res.OK || res.ExitIP != "203.0.113.7" || res.Via != "direct" { - t.Fatalf("得到 %+v,期望成功且 via=direct、IP=203.0.113.7", res) + if !res.OK || res.ExitIP != "203.0.113.7" || res.Via != "http" { + t.Fatalf("得到 %+v,期望成功且 via=http、IP=203.0.113.7", res) } }, }, @@ -85,7 +103,7 @@ func TestProbeEgress(t *testing.T) { }, }, { - name: "HTML 不被当成 IP", proxy: "", probes: []string{htmlSrv.URL}, timeout: 5 * time.Second, + name: "HTML 不被当成 IP", proxy: proxy.URL, probes: []string{htmlSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { if res.OK { t.Fatalf("反爬页/错误页不该被当成 IP,实际 exit_ip=%q", res.ExitIP) @@ -93,7 +111,7 @@ func TestProbeEgress(t *testing.T) { }, }, { - name: "第一个地址失败自动换下一个", proxy: "", + name: "第一个地址失败自动换下一个", proxy: proxy.URL, probes: []string{deniedSrv.URL, fallbackSrv.URL}, timeout: 5 * time.Second, check: func(t *testing.T, res egressProbeResult) { if !res.OK || res.ExitIP != "203.0.113.42" { @@ -158,7 +176,32 @@ func TestProbeEgressTrustsCustomCA(t *testing.T) { if res := probeEgress(context.Background(), "", "", []string{srv.URL}, 5*time.Second); res.OK { t.Fatal("不信任自签 CA 时不该成功") } - res := probeEgress(context.Background(), "", caPath, []string{srv.URL}, 5*time.Second) + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + http.Error(w, "CONNECT required", http.StatusMethodNotAllowed) + return + } + upstream, err := net.Dial("tcp", r.Host) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + client, rw, err := http.NewResponseController(w).Hijack() + if err != nil { + _ = upstream.Close() + return + } + _, _ = fmt.Fprint(rw, "HTTP/1.1 200 Connection Established\r\n\r\n") + _ = rw.Flush() + go func() { + _, _ = io.Copy(upstream, client) + _ = upstream.Close() + }() + _, _ = io.Copy(client, upstream) + _ = client.Close() + })) + defer proxy.Close() + res := probeEgress(context.Background(), proxy.URL, caPath, []string{srv.URL}, 5*time.Second) if !res.OK || res.ExitIP != "192.0.2.5" { t.Fatalf("信任 CA 后应拿到 IP,得到 %+v / %+v", res.Error, res.Probes) } @@ -361,3 +404,105 @@ func hasSubstr(list []string, sub string) bool { } return false } + +// TestValidateProbeURL 锁住 probe_url 的放行边界:私网放行、本机与链路本地拒绝。 +func TestValidateProbeURL(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") // probe_url 不得受 repeater 的 escape hatch 影响 + + ok := []string{ + "http://192.168.132.130:19000/", // 自定义探测服务的主用途 + "https://api.ipify.org?format=json", + "http://10.0.0.5:8080/", + "https://172.16.10.20/", + "http://example.com/", + } + for _, raw := range ok { + if _, err := validateProbeURL(raw); err != nil { + t.Fatalf("%s 应该放行,却报 %v", raw, err) + } + } + + bad := map[string]string{ + "ftp://example.com/": "只支持 http / https", + "file:///etc/passwd": "只支持 http / https", + "/relative/path": "只支持 http / https", + "http:///nohost": "缺少主机名", + "http://127.0.0.1:8080/": "拒绝访问本机", + "http://localhost:8080/": "拒绝访问本机", + "http://169.254.169.254/latest/": "拒绝访问本机", + "http://[::1]:8080/": "拒绝访问本机", + } + for raw, want := range bad { + _, err := validateProbeURL(raw) + if err == nil { + t.Fatalf("%s 应该被拒绝", raw) + } + if !strings.Contains(err.Error(), want) { + t.Fatalf("%s 的报错应包含 %q,实际 %v", raw, want, err) + } + } +} + +// TestProbeCheckRedirect 确认重定向逐跳校验:跨主机、本机和过多跳数都断。 +func TestProbeCheckRedirect(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "") + + via := []*http.Request{httptest.NewRequest(http.MethodGet, "https://api.ipify.org", nil)} + if err := probeCheckRedirect(httptest.NewRequest(http.MethodGet, "https://api.ipify.org/ip", nil), via); err != nil { + t.Fatalf("同主机重定向应放行,实际 %v", err) + } + if err := probeCheckRedirect(httptest.NewRequest(http.MethodGet, "https://ifconfig.me/ip", nil), via); err == nil { + t.Fatal("跨公网主机的重定向必须拒绝") + } + if err := probeCheckRedirect(httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8787/api/settings", nil), via); err == nil { + t.Fatal("重定向到本机必须拒绝") + } + if err := probeCheckRedirect(httptest.NewRequest(http.MethodGet, "https://api.ipify.org", nil), + []*http.Request{via[0], via[0], via[0]}); err == nil { + t.Fatal("超过 3 跳必须拒绝") + } + + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ip":"203.0.113.9"}`)) + })) + defer target.Close() + redirector := httptest.NewServer(http.RedirectHandler(target.URL, http.StatusFound)) + defer redirector.Close() + _, err := (&http.Client{CheckRedirect: probeCheckRedirect}).Get(redirector.URL) + if err == nil || !strings.Contains(err.Error(), "拒绝跨主机重定向") { + t.Fatalf("httptest 跨主机 302 应失败,得到 %v", err) + } +} + +// TestProbeEgressBlocksLoopback 确认拨号阶段真的挡住了本机地址(URL 校验之外的兜底, +// 防的是"域名解析后才变成 127.0.0.1")。 +func TestProbeEgressBlocksLoopback(t *testing.T) { + t.Setenv(repeaterAllowLocalEnv, "1") + srv := fakeIPServer("203.0.113.7") + defer srv.Close() + + res := probeEgress(context.Background(), "", "", []string{srv.URL}, 2*time.Second) + if res.OK { + t.Fatalf("连 127.0.0.1 应该失败,实际 %+v", res) + } + if len(res.Probes) != 1 || !strings.Contains(res.Probes[0].Error, "拒绝访问本机") { + t.Fatalf("错误应说明是本机地址被拒,实际 %+v", res.Probes) + } + if strings.Contains(res.Probes[0].Error, repeaterAllowLocalEnv) { + t.Fatalf("probe 错误不应提示 repeater local escape hatch,实际 %+v", res.Probes[0]) + } +} + +// TestMaskSecrets 确认凭据不会随结果漏到界面上。 +func TestMaskSecrets(t *testing.T) { + cases := map[string]string{ + "http://user:pass@192.168.1.5:8080": "http://***@192.168.1.5:8080", + "http://192.168.1.5:8080": "http://192.168.1.5:8080", + `socks5://u:p@h:1080: dial tcp`: "socks5://***@h:1080: dial tcp", + } + for in, want := range cases { + if got := maskSecrets(in); got != want { + t.Fatalf("maskSecrets(%q) = %q,期望 %q", in, got, want) + } + } +} diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 779a2bf..ebb8564 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -45,6 +45,14 @@ export default function SystemSettingsPage() { // 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。 const [egressTesting, setEgressTesting] = React.useState(false); const [egressReport, setEgressReport] = React.useState