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:
@@ -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 校验出站 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, ::
|
||||
}
|
||||
@@ -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("非白名单主机不应放行")
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user