diff --git a/monkeyai/.env.example b/monkeyai/.env.example index b5240e503..8eb71fb50 100644 --- a/monkeyai/.env.example +++ b/monkeyai/.env.example @@ -29,7 +29,7 @@ RUSTFS_SECRET_KEY=change-this-rustfs-admin-secret # MONKEYAI_S3_REGION=us-east-1 # MONKEYAI_MCP_ALLOWED_CIDRS=10.0.0.0/8,192.168.0.0/16 -# 后端访问外部 HTTP(S) 服务时使用的代理。仅 HTTPS 目标可单独设置 HTTPS_PROXY。 +# 后端 OAuth、S3 等请求使用的代理;模型、图片及 MCP 工具调用始终直连,MCP OAuth 令牌交换可代理。 # HTTP_PROXY=http://proxy.example.com:7890 # HTTPS_PROXY=http://proxy.example.com:7890 # 内部服务必须直连;自定义时保留 db、rustfs 和本机地址。 diff --git a/monkeyai/README.md b/monkeyai/README.md index 425c74160..ab5dfe22c 100644 --- a/monkeyai/README.md +++ b/monkeyai/README.md @@ -17,13 +17,15 @@ docker compose up --build ## 后端出站代理 -在 `.env` 中设置 `HTTP_PROXY`、`HTTPS_PROXY`(例如 `http://proxy.example.com:7890`),Compose 会将其传入 backend;不配置时保持直连。修改后运行 `docker compose up -d backend` 重建容器。可通过 `MONKEYAI_NO_PROXY` 指定直连地址,默认包含 `db,rustfs,localhost,127.0.0.1,::1`;覆盖时应保留这些地址及其他内网服务域名,避免内部流量误走代理。代理凭据如写入 `.env`,应限制该文件的读取权限。 +在 `.env` 中设置 `HTTP_PROXY`、`HTTPS_PROXY`(例如 `http://proxy.example.com:7890`),Compose 会将其传入 backend,供 OAuth 和对象存储等非模型代理请求使用;不配置时保持直连。修改后运行 `docker compose up -d backend` 重建容器。可通过 `MONKEYAI_NO_PROXY` 指定直连地址,默认包含 `db,rustfs,localhost,127.0.0.1,::1`;覆盖时应保留这些地址及其他内网服务域名,避免内部流量误走代理。代理凭据如写入 `.env`,应限制该文件的读取权限。 | 出站请求 | 代理行为 | |---|---| | GitHub、Google、Microsoft、GitLab、OIDC 等登录提供方的元数据、令牌和用户信息 | 后端 HTTP(S) 请求使用环境代理;授权页面跳转由用户浏览器访问,不经过后端代理 | -| 文本模型上游、图片生成上游(OpenAI、xAI、火山引擎等)、Responses 用量补偿及 S3 对象存储 | 后端 HTTP(S) 请求使用环境代理;S3 等内网服务需配置直连 | -| 远程 MCP 连接器与其 OAuth 元数据、注册和令牌请求 | 支持环境代理;代理模式仅允许目标为通过地址策略检查的 IP 字面量,域名目标无法校验代理端 DNS 结果,会被拒绝;`NO_PROXY` 命中时仍按原有直连策略检查 | +| S3 对象存储 | 使用环境代理;内网存储服务需配置直连 | +| llmproxy 文本模型上游及 Responses 用量补偿、imageproxy 图片生成上游(OpenAI、xAI、火山引擎等) | 始终直连,不读取环境代理配置 | +| 远程 MCP 工具调用、OAuth 元数据与动态注册 | 始终直连,继续执行目标地址限制和禁止重定向策略 | +| MCP OAuth 首次交换及刷新令牌 | 允许经 `HTTPS_PROXY` 访问 HTTPS 令牌端点(代理地址须为 HTTP 代理);无代理或 `NO_PROXY` 命中时直连。代理请求会在本地检查目标解析结果,CONNECT 固定到获准的 IP,仍校验原域名的 TLS 证书 | | 百智云钱包 SDK | SDK 的专用 mTLS 传输未提供代理配置,仍然直连 | | SMTP 邮件及 Admin 的 ACME 证书签发/续期 | 不使用后端 HTTP(S) 代理;分别通过 TCP 直连和 Admin 容器独立发起 | diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index 37eb12221..80037cf31 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -131,7 +131,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo imageInputs := imagegen.NewInputs(imageRepo, storage) imageOutputs := imagegen.NewOutputs(imageRepo, storage) imageService := imagegen.NewService(modelRepo, imageRepo, imageInputs, imageOutputs, charges) - upstreamClient := &http.Client{Timeout: 10 * time.Minute} + upstreamClient := &http.Client{Timeout: 10 * time.Minute, Transport: proxy.DirectTransport()} gptImages := openaiimages.New(upstreamClient) gptResponses := openairesponses.New(upstreamClient) seedream := volcengine.New(upstreamClient) diff --git a/monkeyai/backend/internal/mcp/oauth.go b/monkeyai/backend/internal/mcp/oauth.go index 49a5f0654..298ab0260 100644 --- a/monkeyai/backend/internal/mcp/oauth.go +++ b/monkeyai/backend/internal/mcp/oauth.go @@ -407,6 +407,10 @@ func safeMCPFailure(err error) []any { } func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, error) { + return exchangeWithProxy(ctx, c, v, http.ProxyFromEnvironment) +} + +func exchangeWithProxy(ctx context.Context, c resource.Object, v url.Values, proxy func(*http.Request) (*url.URL, error)) (tokens, error) { o := oauthSettings(c) if o.clientSecretExpired() { return tokens{}, invalidGrant @@ -419,6 +423,8 @@ func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, err if secret != "" && (o.TokenAuthMethod == "" || o.TokenAuthMethod == "client_secret_post") { v.Set("client_secret", secret) } + ctx, cancel := context.WithTimeout(ctx, 25*time.Second) + defer cancel() req, err := http.NewRequestWithContext(ctx, "POST", o.TokenURL, strings.NewReader(v.Encode())) if err != nil { return tokens{}, tokenExchangeError{reason: "invalid_token_url"} @@ -428,7 +434,10 @@ func exchange(ctx context.Context, c resource.Object, v url.Values) (tokens, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") - h := client() + h, req, err := tokenClient(req, proxy, net.DefaultResolver.LookupNetIP) + if err != nil { + return tokens{}, err + } defer h.CloseIdleConnections() resp, err := h.Do(req) if err != nil { diff --git a/monkeyai/backend/internal/mcp/oauth_test.go b/monkeyai/backend/internal/mcp/oauth_test.go index 6234a28fb..6849638eb 100644 --- a/monkeyai/backend/internal/mcp/oauth_test.go +++ b/monkeyai/backend/internal/mcp/oauth_test.go @@ -52,6 +52,43 @@ func TestExchangeHTTPFailureExcludesResponse(t *testing.T) { } } +func TestExchangeNoProxyKeepsDirectPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + http.Redirect(w, r, "/token", http.StatusFound) + return + } + resource.JSON(w, http.StatusOK, resource.Object{"access_token": "direct"}) + })) + defer server.Close() + noProxy := func(*http.Request) (*url.URL, error) { return nil, nil } + config := func(target string) resource.Object { + return resource.Object{"oauth_config": oauthConfig{ClientID: "client", TokenURL: target}} + } + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "") + if _, err := exchangeWithProxy(t.Context(), config(server.URL), url.Values{"grant_type": {"refresh_token"}}, noProxy); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { + t.Fatalf("未继承直连目标 IP 校验: %v", err) + } + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") + result, err := exchangeWithProxy(t.Context(), config(server.URL), url.Values{"grant_type": {"authorization_code"}}, noProxy) + if err != nil || result.Access != "direct" { + t.Fatalf("无代理时 OAuth 授权交换未直连: %+v %v", result, err) + } + if _, err := exchangeWithProxy(t.Context(), config(server.URL+"/redirect"), url.Values{"grant_type": {"refresh_token"}}, noProxy); err == nil || !strings.Contains(err.Error(), "不允许自动重定向") { + t.Fatalf("未继承直连禁重定向策略: %v", err) + } +} + +func TestExchangeRejectsHTTPProxyTarget(t *testing.T) { + proxyURL, _ := url.Parse("http://127.0.0.1:3128") + _, err := exchangeWithProxy(t.Context(), resource.Object{"oauth_config": oauthConfig{TokenURL: "http://example.com/token"}}, url.Values{"grant_type": {"refresh_token"}}, func(*http.Request) (*url.URL, error) { + return proxyURL, nil + }) + if err == nil || !strings.Contains(err.Error(), "仅支持 HTTPS 目标") { + t.Fatalf("HTTP Token URL 不得由代理解析: %v", err) + } +} + func TestOAuthFailureDoesNotExposeURL(t *testing.T) { err := &url.Error{Op: "POST", URL: "https://oauth.example/token?code=private-code", Err: errors.New("private-token")} logged := fmt.Sprint(safeMCPFailure(err)) diff --git a/monkeyai/backend/internal/mcp/transport.go b/monkeyai/backend/internal/mcp/transport.go index 2445b6aaa..e75f9b07c 100644 --- a/monkeyai/backend/internal/mcp/transport.go +++ b/monkeyai/backend/internal/mcp/transport.go @@ -2,6 +2,7 @@ package mcp import ( "context" + "crypto/tls" "encoding/json" "fmt" "net" @@ -29,75 +30,68 @@ func allowedIP(ip netip.Addr) bool { return ip.IsGlobalUnicast() && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() } -type proxyRoute struct{ url *url.URL } - -type proxyRouteKey struct{} - -type mcpTransport struct{ *http.Transport } - -func (t *mcpTransport) RoundTrip(r *http.Request) (*http.Response, error) { - proxy, err := http.ProxyFromEnvironment(r) - if err != nil { - return nil, err - } - route := proxyRoute{url: proxy} - if proxy != nil { - // 代理端解析域名时无法验证解析结果;只允许代理访问已获准的 IP 字面量。 - ip, err := netip.ParseAddr(r.URL.Hostname()) - if err != nil || ip.Zone() != "" { - return nil, fmt.Errorf("代理目标必须是无区域标识的 IP 地址,无法验证代理端 DNS") - } - if !allowedIP(ip) { - return nil, fmt.Errorf("目标地址不在允许范围内") +func client() *http.Client { + return &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return fmt.Errorf("不允许自动重定向") }, Transport: &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err } - if proxy.Scheme != "http" && proxy.Scheme != "https" { - return nil, fmt.Errorf("不支持的代理协议") + ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) + if err != nil { + return nil, err } - } - return t.Transport.RoundTrip(r.WithContext(context.WithValue(r.Context(), proxyRouteKey{}, route))) -} - -func client() *http.Client { - transport := &http.Transport{ - Proxy: func(r *http.Request) (*url.URL, error) { - route, ok := r.Context().Value(proxyRouteKey{}).(proxyRoute) - if !ok { - return nil, fmt.Errorf("缺少代理路由") + for _, ip := range ips { + if !allowedIP(ip) { + return nil, fmt.Errorf("目标地址不在允许范围内") } - return route.url, nil - }, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - host, port, err := net.SplitHostPort(addr) - if err != nil { - return nil, err - } - ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host) - if err != nil { - return nil, err - } - route, ok := ctx.Value(proxyRouteKey{}).(proxyRoute) - if !ok { - return nil, fmt.Errorf("缺少代理路由") - } - // 使用代理时 DialContext 只连接代理;目标 IP 已在 RoundTrip 中校验。 - if route.url == nil { - for _, ip := range ips { - if !allowedIP(ip) { - return nil, fmt.Errorf("目标地址不在允许范围内") - } - } - } - for _, ip := range ips { - c, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) - if err == nil { - return c, nil - } + } + for _, ip := range ips { + c, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + if err == nil { + return c, nil } - return nil, fmt.Errorf("无法连接目标") - }, + } + return nil, fmt.Errorf("无法连接目标") + }}} +} +func tokenClient(req *http.Request, proxy func(*http.Request) (*url.URL, error), lookup func(context.Context, string, string) ([]netip.Addr, error)) (*http.Client, *http.Request, error) { + h := client() + p, err := proxy(req) + if err != nil || p == nil { + return h, req, err + } + if req.URL.Scheme != "https" || p.Scheme != "http" || p.Host == "" { + return nil, nil, fmt.Errorf("OAuth Token 代理仅支持 HTTPS 目标和 HTTP 代理") } - return &http.Client{Timeout: 25 * time.Second, CheckRedirect: func(r *http.Request, via []*http.Request) error { return fmt.Errorf("不允许自动重定向") }, Transport: &mcpTransport{transport}} + name := req.URL.Hostname() + ips, err := lookup(req.Context(), "ip", name) + if err != nil { + return nil, nil, err + } + if len(ips) == 0 { + return nil, nil, fmt.Errorf("无法解析 OAuth Token 目标") + } + for _, ip := range ips { + if ip.Zone() != "" || !allowedIP(ip) { + return nil, nil, fmt.Errorf("目标地址不在允许范围内") + } + } + target := req.Clone(req.Context()) + u := *req.URL + port := u.Port() + if port == "" { + port = "443" + } + u.Host = net.JoinHostPort(ips[0].Unmap().String(), port) + target.URL = &u + target.Host = req.URL.Host + h.Transport = &http.Transport{ + Proxy: func(*http.Request) (*url.URL, error) { return p, nil }, + TLSClientConfig: &tls.Config{ServerName: name}, + } + return h, target, nil } + func validURL(value string) bool { u, err := url.Parse(value) return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.User == nil && u.Fragment == "" diff --git a/monkeyai/backend/internal/mcp/transport_test.go b/monkeyai/backend/internal/mcp/transport_test.go index 05c5344c7..a13802e44 100644 --- a/monkeyai/backend/internal/mcp/transport_test.go +++ b/monkeyai/backend/internal/mcp/transport_test.go @@ -2,14 +2,19 @@ package mcp import ( "context" + "crypto/x509" "encoding/json" + "io" + "net" "net/http" "net/http/httptest" "net/netip" + "net/url" "os" "os/exec" "strings" "testing" + "time" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" ) @@ -27,6 +32,8 @@ func TestOutboundPolicy(t *testing.T) { } func TestDiscoveryPagination(t *testing.T) { t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") pages := 0 s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var in struct { @@ -62,88 +69,213 @@ func TestDiscoveryPagination(t *testing.T) { } } -func TestHTTPProxyPolicy(t *testing.T) { - if os.Getenv("MCP_PROXY_TEST_CHILD") == "" { - cmd := exec.Command(os.Args[0], "-test.run=^TestHTTPProxyPolicy$") - cmd.Env = append(os.Environ(), "MCP_PROXY_TEST_CHILD=1") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("代理策略子进程失败: %v\n%s", err, out) +func TestToolCallIgnoresEnvironmentProxy(t *testing.T) { + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var call struct { + ID int `json:"id"` + Method string `json:"method"` } - return - } - - var requests []string - proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests = append(requests, r.Method+" "+r.RequestURI) - if r.Method == http.MethodConnect { - w.WriteHeader(http.StatusBadGateway) - } else if r.URL.Path == "/redirect" { - http.Redirect(w, r, "http://169.254.169.254/latest/meta-data", http.StatusFound) - } else { - w.WriteHeader(http.StatusOK) + if err := json.NewDecoder(r.Body).Decode(&call); err != nil { + t.Error(err) + return } + if call.Method == "notifications/initialized" { + w.WriteHeader(http.StatusAccepted) + return + } + result := resource.Object{"ok": true} + if call.Method == "initialize" { + result = resource.Object{"protocolVersion": "2025-03-26"} + } + resource.JSON(w, http.StatusOK, resource.Object{"jsonrpc": "2.0", "id": call.ID, "result": result}) })) - defer proxy.Close() - t.Setenv("HTTP_PROXY", proxy.URL) - t.Setenv("HTTPS_PROXY", proxy.URL) - t.Setenv("http_proxy", "") - t.Setenv("https_proxy", "") - t.Setenv("NO_PROXY", "127.0.0.1,localhost,198.18.0.1") - t.Setenv("no_proxy", "") - - direct := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer direct.Close() - request := func(target string) (*http.Response, error) { - c := client() - defer c.CloseIdleConnections() - return c.Get(target) + defer server.Close() + rpc, err := openRemote(t.Context(), server.URL, nil) + if err != nil { + t.Fatal(err) + } + defer rpc.close() + result, err := rpc.call(t.Context(), 2, "tools/call", resource.Object{"name": "test"}) + if err != nil || !strings.Contains(string(result), `"ok":true`) { + t.Fatalf("工具调用未直连: %s %v", result, err) } +} - if _, err := request(direct.URL); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { - t.Fatalf("直连私网未拦截: %v", err) +func TestClientDirectTransport(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + t.Setenv("NO_PROXY", "example.invalid") + transport, ok := client().Transport.(*http.Transport) + if !ok || transport.Proxy != nil { + t.Fatal("MCP transport 不应设置代理回调") } - if len(requests) != 0 { - t.Fatalf("NO_PROXY 直连却访问了代理: %v", requests) + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + http.Redirect(w, r, "/", http.StatusFound) + return + } + w.WriteHeader(http.StatusNoContent) + })) + defer s.Close() + c := client() + defer c.CloseIdleConnections() + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "") + if _, err := c.Get(s.URL); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { + t.Fatalf("直连地址限制失效: %v", err) } t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") - resp, err := request(direct.URL) - if err != nil || resp.StatusCode != http.StatusNoContent { - t.Fatalf("NO_PROXY 直连失败: %v, %v", resp, err) + resp, err := c.Get(s.URL) + if err != nil { + t.Fatalf("白名单内直连失败: %v", err) } resp.Body.Close() - if _, err = request("http://198.18.0.1/"); err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { - t.Fatalf("NO_PROXY 目标未受直连限制: %v", err) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("直连返回状态错误: %d", resp.StatusCode) } - if len(requests) != 0 { - t.Fatalf("直连请求意外访问代理: %v", requests) + if _, err := c.Get(s.URL + "/redirect"); err == nil || !strings.Contains(err.Error(), "不允许自动重定向") { + t.Fatalf("重定向未拦截: %v", err) } +} - resp, err = request("http://1.1.1.1/mcp") - if err != nil || resp.StatusCode != http.StatusOK || len(requests) != 1 || requests[0] != "GET http://1.1.1.1/mcp" { - t.Fatalf("HTTP 代理未使用本地代理: %v, %v, %v", resp, err, requests) +func TestTokenProxyPinsConnectIPAndKeepsTLSIdentity(t *testing.T) { + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8") + var host, serverName string + token := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, serverName = r.Host, r.TLS.ServerName + w.WriteHeader(http.StatusNoContent) + })) + defer token.Close() + proxyTarget := make(chan string, 2) + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect { + t.Errorf("代理收到非 CONNECT 请求: %s", r.Method) + w.WriteHeader(http.StatusBadRequest) + return + } + proxyTarget <- r.Host + upstream, err := net.Dial("tcp", r.Host) + if err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadGateway) + return + } + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + upstream.Close() + t.Error(err) + return + } + defer conn.Close() + defer upstream.Close() + if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + t.Error(err) + return + } + go func() { _, _ = io.Copy(upstream, conn); _ = upstream.Close() }() + _, _ = io.Copy(conn, upstream) + })) + defer proxy.Close() + p, _ := url.Parse(proxy.URL) + proxyForToken := func(*http.Request) (*url.URL, error) { return p, nil } + resolve := func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil + } + address, _ := url.Parse(token.URL) + address.Host = net.JoinHostPort("example.com", address.Port()) + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, address.String(), nil) + if err != nil { + t.Fatal(err) + } + h, target, err := tokenClient(req, proxyForToken, resolve) + if err != nil { + t.Fatal(err) + } + defer h.CloseIdleConnections() + roots := x509.NewCertPool() + roots.AddCert(token.Certificate()) + h.Transport.(*http.Transport).TLSClientConfig.RootCAs = roots + resp, err := h.Do(target) + if err != nil { + t.Fatalf("可信证书的代理请求失败: %v", err) } resp.Body.Close() - for _, target := range []string{"http://10.1.2.3/", "https://169.254.169.254/", "http://example.com/", "https://example.com/", "http://[2001:4860:4860::8888%25lo0]/"} { - if _, err = request(target); err == nil { - t.Fatalf("代理目标未拦截: %s", target) + select { + case connect := <-proxyTarget: + if resp.StatusCode != http.StatusNoContent || host != address.Host || serverName != "example.com" || connect != net.JoinHostPort("127.0.0.1", address.Port()) { + t.Fatalf("代理 CONNECT、HTTP Host 或 TLS SNI 不正确: connect=%q status=%d host=%q sni=%q", connect, resp.StatusCode, host, serverName) } + case <-time.After(time.Second): + t.Fatal("Token 请求未通过代理 CONNECT") + } + + address.Host = net.JoinHostPort("untrusted.example", address.Port()) + req, _ = http.NewRequestWithContext(t.Context(), http.MethodPost, address.String(), nil) + h, target, err = tokenClient(req, proxyForToken, resolve) + if err != nil { + t.Fatal(err) } - if len(requests) != 1 { - t.Fatalf("被拒绝的请求访问了代理: %v", requests) + defer h.CloseIdleConnections() + h.Transport.(*http.Transport).TLSClientConfig.RootCAs = roots + if resp, err := h.Do(target); err == nil { + resp.Body.Close() + t.Fatal("代理模式放宽了 TLS 证书主机名校验") } +} - t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "127.0.0.0/8,10.1.0.0/16") - resp, err = request("http://10.1.2.3/") - if err != nil || resp.StatusCode != http.StatusOK || len(requests) != 2 || requests[1] != "GET http://10.1.2.3/" { - t.Fatalf("代理目标白名单无效: %v, %v, %v", resp, err, requests) +func TestTokenProxyRejectsUnsafeTargets(t *testing.T) { + p, _ := url.Parse("http://127.0.0.1:3128") + proxy := func(*http.Request) (*url.URL, error) { return p, nil } + for _, tc := range []struct { + name, target string + ips []netip.Addr + }{ + {"仅内网", "https://example.com/token", []netip.Addr{netip.MustParseAddr("10.0.0.1")}}, + {"混合解析", "https://example.com/token", []netip.Addr{netip.MustParseAddr("8.8.8.8"), netip.MustParseAddr("127.0.0.1")}}, + {"带区域标识", "https://example.com/token", []netip.Addr{netip.MustParseAddr("2001:4860:4860::8888%lo0")}}, + {"HTTP 目标", "http://example.com/token", []netip.Addr{netip.MustParseAddr("8.8.8.8")}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MONKEYAI_MCP_ALLOWED_CIDRS", "") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, tc.target, nil) + _, _, err := tokenClient(req, proxy, func(context.Context, string, string) ([]netip.Addr, error) { return tc.ips, nil }) + if err == nil { + t.Fatal("不安全的目标地址通过了代理校验") + } + }) } - resp.Body.Close() - if _, err = request("https://1.1.1.1/"); err == nil || len(requests) != 3 || requests[2] != "CONNECT 1.1.1.1:443" { - t.Fatalf("HTTPS CONNECT 未按目标 IP 发起: %v, %v", err, requests) + secureProxy, _ := url.Parse("https://127.0.0.1:3128") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com/token", nil) + if _, _, err := tokenClient(req, func(*http.Request) (*url.URL, error) { return secureProxy, nil }, nil); err == nil { + t.Fatal("不支持的 HTTPS 代理未被拒绝") + } +} + +func TestTokenEnvironmentNoProxy(t *testing.T) { + if os.Getenv("MCP_PROXY_TEST_CHILD") == "1" { + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com/token", nil) + h, direct, err := tokenClient(req, http.ProxyFromEnvironment, func(context.Context, string, string) ([]netip.Addr, error) { + t.Fatal("NO_PROXY 命中时不应走代理域名解析") + return nil, nil + }) + if err != nil || h.Transport.(*http.Transport).Proxy != nil || direct.URL.Host != req.URL.Host { + t.Fatalf("NO_PROXY 未保持直连策略: %v", err) + } + req, _ = http.NewRequestWithContext(t.Context(), http.MethodPost, "https://other.example/token", nil) + _, _, err = tokenClient(req, http.ProxyFromEnvironment, func(context.Context, string, string) ([]netip.Addr, error) { + return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil + }) + if err == nil || !strings.Contains(err.Error(), "目标地址不在允许范围内") { + t.Fatalf("环境代理未对非 NO_PROXY 域名执行目标 IP 校验: %v", err) + } + return } - if _, err = request("http://1.1.1.1/redirect"); err == nil || !strings.Contains(err.Error(), "不允许自动重定向") || len(requests) != 4 { - t.Fatalf("代理重定向未拦截: %v, %v", err, requests) + cmd := exec.Command(os.Args[0], "-test.run=^TestTokenEnvironmentNoProxy$") + cmd.Env = append(os.Environ(), "MCP_PROXY_TEST_CHILD=1", "HTTPS_PROXY=http://127.0.0.1:3128", "NO_PROXY=example.com") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("子进程代理环境测试失败: %v\n%s", err, output) } } diff --git a/monkeyai/backend/internal/proxy/proxy.go b/monkeyai/backend/internal/proxy/proxy.go index 1c8780015..9edf957ed 100644 --- a/monkeyai/backend/internal/proxy/proxy.go +++ b/monkeyai/backend/internal/proxy/proxy.go @@ -78,6 +78,12 @@ type Proxy struct { reverse *httputil.ReverseProxy } +func DirectTransport() *http.Transport { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return transport +} + func NewProxy(resolver Resolver, logger *slog.Logger) *Proxy { if logger == nil { logger = slog.Default() @@ -87,7 +93,7 @@ func NewProxy(resolver Resolver, logger *slog.Logger) *Proxy { logger: logger.With("module", "proxy"), } p.reverse = &httputil.ReverseProxy{ - Transport: http.DefaultTransport, + Transport: DirectTransport(), Rewrite: p.rewrite, ModifyResponse: p.modifyResponse, ErrorHandler: p.errorHandler, diff --git a/monkeyai/backend/internal/proxy/proxy_test.go b/monkeyai/backend/internal/proxy/proxy_test.go index 8df1501d0..7865d4080 100644 --- a/monkeyai/backend/internal/proxy/proxy_test.go +++ b/monkeyai/backend/internal/proxy/proxy_test.go @@ -215,6 +215,20 @@ func TestProxyRegister(t *testing.T) { } } +func TestModelTransportsIgnoreEnvironmentProxy(t *testing.T) { + t.Setenv("HTTP_PROXY", "http://127.0.0.1:1") + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:1") + for _, transport := range []*http.Transport{ + NewProxy(nil, discardLogger()).reverse.Transport.(*http.Transport), + NewResponseReconciler().client.Transport.(*http.Transport), + DirectTransport(), + } { + if transport.Proxy != nil || transport == http.DefaultTransport { + t.Fatal("模型和图片上游不应使用环境代理或共享默认传输层") + } + } +} + func TestProxyChainConfiguration(t *testing.T) { proxy := NewProxy(nil, discardLogger()) recorder := &usageRecorderStub{calls: make(chan Call, 1)} diff --git a/monkeyai/backend/internal/proxy/reconcile.go b/monkeyai/backend/internal/proxy/reconcile.go index 01fc28023..4b2209775 100644 --- a/monkeyai/backend/internal/proxy/reconcile.go +++ b/monkeyai/backend/internal/proxy/reconcile.go @@ -29,7 +29,7 @@ type ResponseReconciler struct { func NewResponseReconciler() *ResponseReconciler { return &ResponseReconciler{client: &http.Client{ - Transport: http.DefaultTransport, + Transport: DirectTransport(), CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },