feat(mcp-go): external_api 通用出站 HTTP 工具(带 SSRF 防护)

新增 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 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-18 11:58:45 +08:00
parent 6523323a27
commit aa3139da68
4 changed files with 194 additions and 2 deletions
+135
View File
@@ -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 == "<NIL>" {
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 校验出站 URLscheme 限 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, ::
}
@@ -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("非白名单主机不应放行")
}
}
+2
View File
@@ -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)}