security: harden egress probe from PR56

This commit is contained in:
RuoJi6 committed 2026-10-27 01:32:45 -07:00
1 parent ff7839928a
commit 2d5f41d5b8
4 files changed
+312 -25

No files matched your search

+118 -12
View File
@@ -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+
"。上方结果对应输入框中的值,保存后生效。")
}
}
+151 -6
View File
@@ -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("<html><body>access denied</body></html>"))
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:[email protected]: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)
}
}
}
+21 -7
View File
@@ -45,6 +45,14 @@ export default function SystemSettingsPage() {
// 出口探测:测一次真实请求,拿到"直连 / 经全局代理"两条出口 IP。
const [egressTesting, setEgressTesting] = React.useState(false);
const [egressReport, setEgressReport] = React.useState<EgressReport | null>(null);
// 请求序号:改了输入/保存/切捕获之后,在途请求回来时序号已经变了,直接丢弃。
// 不这么做会出现"结果刚被清空,又被上一次的响应填回来"。
const egressSeq = React.useRef(0);
// 清空结果并作废在途请求。
const clearEgressReport = () => {
egressSeq.current += 1;
setEgressReport(null);
};
// 探测地址:留空用公网回显;隔离网填内网回显服务。
const [egressProbeUrl, setEgressProbeUrl] = React.useState("");
const [testing, setTesting] = React.useState(false);
@@ -199,7 +207,7 @@ export default function SystemSettingsPage() {
.then((s) => {
apply(s);
// 捕获开关一变,② 的链路就变了,旧结果不能留。
setEgressReport(null);
clearEgressReport();
})
.catch(() => setTrafficCapture(!v)) // revert on failure
.finally(() => setSaving(false));
@@ -331,7 +339,7 @@ export default function SystemSettingsPage() {
.then((s) => {
apply(s);
// 保存后链路变了,旧结果(尤其"尚未保存")立刻失效。
setEgressReport(null);
clearEgressReport();
if (showEgressIp) void probeEgress().catch(() => undefined);
toast.success(globalProxyInput.trim() ? "已保存全局代理" : "已清除全局代理(改为直连)");
})
@@ -341,6 +349,7 @@ export default function SystemSettingsPage() {
// 测表单里当前的值,不要求先保存;后端返回直连 / 经代理两条。
const testEgress = () => {
const seq = ++egressSeq.current;
setEgressTesting(true);
setEgressReport(null);
api
@@ -350,6 +359,7 @@ export default function SystemSettingsPage() {
...(egressProbeUrl.trim() ? { probe_url: egressProbeUrl.trim() } : {}),
})
.then((rep) => {
if (seq !== egressSeq.current) return; // 期间改过配置,这份结果已经过期
setEgressReport(rep);
if (rep.ok) {
// 先取 ②:直连那行永远成功,先命中它会把直连 IP 当成出口报出去。
@@ -361,8 +371,12 @@ export default function SystemSettingsPage() {
toast.error("出口探测失败");
}
})
.catch((e) => toast.error("探测失败:" + (e as Error).message))
.finally(() => setEgressTesting(false));
.catch((e) => {
if (seq === egressSeq.current) toast.error("探测失败:" + (e as Error).message);
})
.finally(() => {
if (seq === egressSeq.current) setEgressTesting(false);
});
};
const toggleEgressVisibility = async (nextEnabled: boolean) => {
@@ -515,8 +529,8 @@ export default function SystemSettingsPage() {
disabled={!loaded || savingGlobalProxy}
onChange={(e) => {
setGlobalProxyInput(e.target.value);
setEgressReport(null);
// 旧结果对应的是上一次的地址,留着会让人以为新值已经测过。
clearEgressReport();
}}
/>
<Button type="button" onClick={saveGlobalProxy} disabled={!loaded || savingGlobalProxy}>
@@ -569,7 +583,7 @@ export default function SystemSettingsPage() {
value={egressProbeUrl}
onChange={(e) => {
setEgressProbeUrl(e.target.value);
setEgressReport(null);
clearEgressReport();
}}
/>
<span className="text-muted-foreground leading-relaxed">
+22
View File
@@ -2384,6 +2384,28 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/settings/basic-auth" && m === "PUT")
throw new Error("演示模式不支持修改 HTTP Basic Auth,请在实际部署中配置");
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
if (path === "/settings/proxy/test")
return {
ok: true,
capture: false,
configured: "",
agent_proxy: "",
checks: [
{
key: "direct",
label: "① 直连 IP(不经过代理)",
result: { ok: true, exit_ip: "203.0.113.7", via: "direct", via_label: "直连", latency_ms: 12, probes: [] },
},
{
key: "global",
label: "② 经全局代理的出口 IP",
skipped: true,
skip_reason: "输入框为空,未配置代理",
result: { ok: false, latency_ms: 0, probes: [] },
},
],
warnings: [],
};
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
if (path === "/chat")
return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };