From aa3139da68c29c07601f67753226314ec858d1f3 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Thu, 18 Jun 2026 11:58:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp-go):=20external=5Fapi=20=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E5=87=BA=E7=AB=99=20HTTP=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=EF=BC=88=E5=B8=A6=20SSRF=20=E9=98=B2=E6=8A=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 external_api 工具(GET/POST):agent 图可调外部 API。安全为先: - SSRF 防护 validateExternalURL/isBlockedIP:scheme 限 http/https;拒环回/内网 /链路本地(含 169.254.169.254 云元数据)/未指定 IP;重定向同样校验、限 3 跳。 - 可选 EXTERNAL_API_ALLOWLIST(逗号分隔主机,支持子域)收窄到白名单。 - 超时 10s + 响应体限 256KB。 - 校验逻辑纯函数,单测覆盖(内网/元数据/scheme/白名单,字面量 IP 离线判定)。 注册进 mcp-go dispatch(external_api → externalAPI)。 Co-Authored-By: Claude Opus 4.8 --- PROGRESS.md | 4 +- sundynix-mcp-go/internal/mcp/external.go | 135 ++++++++++++++++++ sundynix-mcp-go/internal/mcp/external_test.go | 55 +++++++ sundynix-mcp-go/internal/mcp/gateway.go | 2 + 4 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 sundynix-mcp-go/internal/mcp/external.go create mode 100644 sundynix-mcp-go/internal/mcp/external_test.go diff --git a/PROGRESS.md b/PROGRESS.md index f24191d..c3f9dfa 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -67,7 +67,7 @@ - [x] memory_get / memory_upsert · history_get / history_append - [x] report_render / report_store / report_export - [x] Word 渲染(🟡 自建零依赖 OOXML,非 UniOffice —— 偏差,UniOffice 商业授权) -- [ ] external_api(外部 API 工具) +- [x] external_api(通用出站 HTTP:SSRF 防护 + 可选主机白名单 + 超时/限重定向/限体;含单测) ### sundynix-mcp-py(算法型) @@ -91,7 +91,7 @@ - [x] **真实登录 / 鉴权(JWT)** —— 后端 + 前端闭环已完成 ✅ - [x] **代码解释器 + 安全沙箱**(AST 守卫 + Docker 隔离已落地 ✅;生产可换 gVisor/Kata) - [x] **Harness 三件全完成** ✅:熔断降级 · 输入护栏 · LLM 评测 · 输出护栏(密钥脱敏) -- [ ] **长期记忆抽取** + external_api 工具 +- [ ] **长期记忆抽取**(external_api 工具已完成 ✅) - [ ] **计费 / 商业化**真实实现 - [ ] 微服务化拆分(Morph B)—— 现为 Monolith First,**按设计如此,非缺陷** diff --git a/sundynix-mcp-go/internal/mcp/external.go b/sundynix-mcp-go/internal/mcp/external.go new file mode 100644 index 0000000..9e64fcc --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/external.go @@ -0,0 +1,135 @@ +package mcp + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/sundynix/sundynix-shared/contract" +) + +const ( + extTimeout = 10 * time.Second + extMaxBytes = 256 * 1024 // 响应体读取上限 +) + +// externalAPI 是通用出站 HTTP 工具(GET/POST)。带 SSRF 防护:拒环回/内网/链路本地/ +// 云元数据地址;可选 EXTERNAL_API_ALLOWLIST 收窄到白名单主机。限超时 + 限响应体大小 + 限重定向。 +func (g *Gateway) externalAPI(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + raw, _ := call.Args["url"].(string) + raw = strings.TrimSpace(raw) + if raw == "" { + return &contract.ToolResult{OK: false, Error: "external_api: url 必填"} + } + method := strings.ToUpper(strings.TrimSpace(fmt.Sprint(call.Args["method"]))) + if method == "" || method == "" { + method = "GET" + } + if method != "GET" && method != "POST" { + return &contract.ToolResult{OK: false, Error: "external_api: 仅支持 GET/POST"} + } + allow := extAllowlist() + if reason, ok := validateExternalURL(raw, allow); !ok { + return &contract.ToolResult{OK: false, Error: "external_api: URL 被拦截 —— " + reason} + } + + var body io.Reader + if b, _ := call.Args["body"].(string); b != "" { + body = strings.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, raw, body) + if err != nil { + return &contract.ToolResult{OK: false, Error: "external_api: " + err.Error()} + } + if hm, ok := call.Args["headers"].(map[string]any); ok { + for k, v := range hm { + req.Header.Set(k, fmt.Sprint(v)) + } + } + client := &http.Client{ + Timeout: extTimeout, + CheckRedirect: func(r *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("重定向过多") + } + if reason, ok := validateExternalURL(r.URL.String(), allow); !ok { + return fmt.Errorf("重定向被拦截:%s", reason) + } + return nil + }, + } + resp, err := client.Do(req) + if err != nil { + return &contract.ToolResult{OK: false, Error: "external_api: " + err.Error()} + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes)) + return &contract.ToolResult{OK: true, Content: fmt.Sprintf("HTTP %d\n%s", resp.StatusCode, string(data))} +} + +// extAllowlist 读取 EXTERNAL_API_ALLOWLIST(逗号分隔主机);空则不限主机(仍有 SSRF 防护)。 +func extAllowlist() []string { + v := strings.TrimSpace(os.Getenv("EXTERNAL_API_ALLOWLIST")) + if v == "" { + return nil + } + var out []string + for _, p := range strings.Split(v, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// validateExternalURL 校验出站 URL:scheme 限 http/https;可选白名单;解析出的 IP 不得为 +// 环回/内网/链路本地/未指定(防 SSRF 打内部服务与 169.254.169.254 云元数据)。 +func validateExternalURL(raw string, allow []string) (reason string, ok bool) { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return "仅支持 http/https", false + } + host := u.Hostname() + if host == "" { + return "缺少主机", false + } + if len(allow) > 0 && !hostAllowed(host, allow) { + return "主机不在允许清单", false + } + ips, err := net.LookupIP(host) + if err != nil || len(ips) == 0 { + return "域名解析失败", false + } + for _, ip := range ips { + if isBlockedIP(ip) { + return "禁止访问内网/环回/元数据地址", false + } + } + return "", true +} + +func hostAllowed(host string, allow []string) bool { + host = strings.ToLower(host) + for _, a := range allow { + a = strings.ToLower(a) + if host == a || strings.HasSuffix(host, "."+a) { + return true + } + } + return false +} + +// isBlockedIP 判断 IP 是否属于禁止出站的范围(SSRF 防护)。 +func isBlockedIP(ip net.IP) bool { + return ip.IsLoopback() || // 127.0.0.0/8, ::1 + ip.IsPrivate() || // 10/8, 172.16/12, 192.168/16, fc00::/7 + ip.IsLinkLocalUnicast() || // 169.254/16(含云元数据), fe80::/10 + ip.IsLinkLocalMulticast() || + ip.IsUnspecified() // 0.0.0.0, :: +} diff --git a/sundynix-mcp-go/internal/mcp/external_test.go b/sundynix-mcp-go/internal/mcp/external_test.go new file mode 100644 index 0000000..e7b2a8c --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/external_test.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "net" + "testing" +) + +func TestIsBlockedIP(t *testing.T) { + blocked := []string{"127.0.0.1", "10.1.2.3", "172.16.0.5", "192.168.1.1", "169.254.169.254", "0.0.0.0", "::1"} + for _, s := range blocked { + if !isBlockedIP(net.ParseIP(s)) { + t.Errorf("%s 应被拦截(内网/环回/元数据)", s) + } + } + allowed := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34"} + for _, s := range allowed { + if isBlockedIP(net.ParseIP(s)) { + t.Errorf("%s 是公网,不应被拦截", s) + } + } +} + +func TestValidateExternalURL_Scheme(t *testing.T) { + for _, raw := range []string{"ftp://x/y", "file:///etc/passwd", "not a url", "ws://h"} { + if reason, ok := validateExternalURL(raw, nil); ok { + t.Errorf("%q 应被拒(非 http/https), got ok reason=%q", raw, reason) + } + } +} + +func TestValidateExternalURL_SSRF(t *testing.T) { + // 字面量 IP 不走真实 DNS,可离线判定。 + for _, raw := range []string{"http://127.0.0.1/admin", "http://169.254.169.254/latest/meta-data/", "http://10.0.0.1/"} { + if _, ok := validateExternalURL(raw, nil); ok { + t.Errorf("%q 应被 SSRF 防护拦截", raw) + } + } + // 公网字面量放行。 + if reason, ok := validateExternalURL("http://8.8.8.8/", nil); !ok { + t.Errorf("公网 IP 应放行, got %q", reason) + } +} + +func TestValidateExternalURL_Allowlist(t *testing.T) { + allow := []string{"api.github.com"} + if _, ok := validateExternalURL("http://8.8.8.8/", allow); ok { + t.Error("白名单生效时,非白名单主机应被拒") + } + if !hostAllowed("api.github.com", allow) || !hostAllowed("sub.api.github.com", allow) { + t.Error("白名单主机及其子域应放行") + } + if hostAllowed("evil.com", allow) { + t.Error("非白名单主机不应放行") + } +} diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index 0491a4c..b013fa7 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -64,6 +64,8 @@ func (g *Gateway) dispatch(ctx context.Context, call *contract.ToolCall) *contra return g.reportStore(ctx, call) case "report_export": return g.reportExport(ctx, call) + case "external_api": + return g.externalAPI(ctx, call) case "health": data, _ := json.Marshal(g.rag.Status()) return &contract.ToolResult{OK: true, Content: string(data)}