From 0a0158a4ab9d285688e77361f0747d0b89dd951e Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 14 Nov 2026 14:32:39 +0800 Subject: [PATCH] feat(mcp): add a source allowlist for the MCP server endpoint Restrict which source addresses may call /mcp with a list of IPs/CIDRs (IPv4 and IPv6). The check runs before token authentication, so a source that is not listed is refused with 403 even when it holds a valid token. An empty list keeps the previous behaviour (any source). Behind a reverse proxy the TCP peer is always the proxy, so proxies can be registered as trusted: for requests from them the client is the rightmost X-Forwarded-For hop that is not itself a trusted proxy. Without a trusted proxy the header is ignored, so a caller cannot claim an allowed address. - A stored configuration that cannot be read or parsed refuses requests instead of falling back to no restriction. - The audit log records the resolved client address. - The settings API reports the address the server sees for the caller. - The allowlist applies to /mcp only; the web UI and admin API are unaffected. Refs RuoJi6/ARTEX#144 --- CHANGELOG.md | 2 +- README.md | 11 +- db/api_tokens.go | 3 + server/mcp_server.go | 89 +++++++-- server/mcp_server_access.go | 178 ++++++++++++++++++ server/mcp_server_test.go | 168 ++++++++++++++++- .../settings/_components/mcp-server-card.tsx | 63 +++++++ web/src/i18n/settings-messages.ts | 22 ++- web/src/lib/api.ts | 6 + 9 files changed, 523 insertions(+), 19 deletions(-) create mode 100644 server/mcp_server_access.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8b15b..9656e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ## [Unreleased] -- 新增 MCP 服务端:在「系统设置 → MCP 服务端」开启后,外部智能体可通过 `/mcp`(Streamable HTTP)查看任务与漏洞、创建任务、暂停/恢复任务、补充提示。新增访问令牌(权限范围、限定企业、任务限额、有效期、吊销)与调用审计。默认关闭;`/mcp` 只认访问令牌,不经过也无法使用 HTTP Basic Auth。详见 README「MCP 服务端」。 +- 新增 MCP 服务端:在「系统设置 → MCP 服务端」开启后,外部智能体可通过 `/mcp`(Streamable HTTP)查看任务与漏洞、创建任务、暂停/恢复任务、补充提示。新增访问令牌(权限范围、限定企业、任务限额、有效期、吊销)与调用审计。支持来源白名单(IP/CIDR,可配置可信反向代理),在令牌校验之前生效。默认关闭;`/mcp` 只认访问令牌,不经过也无法使用 HTTP Basic Auth。详见 README「MCP 服务端」。 - 新增可选「无成果止损」:按实际运行时长、独立漏洞数量与最低等级做一次检查,未达标时自动收尾并显示「无成果停止」;暂停、排队及计划窗口外不计时,复活后重新计时,默认关闭。停止状态复用 `timeout`,已有 `on_task_timeout` 触发器仍会执行并可能消耗 token。 ## [0.3.18] - 2026-10-12 diff --git a/README.md b/README.md index 67b6624..6a1f831 100644 --- a/README.md +++ b/README.md @@ -370,9 +370,18 @@ curl -s https://artex.example.com/mcp \ 原因:一个 HTTP 请求只有一个 `Authorization` 头,外部智能体无法同时带上 Basic 凭证和 Bearer 令牌。因此: - 即使启用了 HTTP Basic Auth,`/mcp` 仍可直接凭访问令牌访问,Basic Auth 不会给它加一层保护。 -- 需要在网络层限制 `/mcp` 的话,请用反向代理或防火墙按来源 IP 限制,不要依赖 Basic Auth。 +- 需要限制谁能连 `/mcp` 的话,请用下文的「来源白名单」,不要依赖 Basic Auth。 - 公网暴露请使用 HTTPS,令牌是明文放在请求头里的。 +#### 来源白名单 + +在「MCP 服务端」卡片的「来源白名单」里填写允许调用 `/mcp` 的来源地址,每行一个 IP 或 CIDR(如 `203.0.113.7`、`10.0.0.0/8`,支持 IPv6)。留空表示不限制来源。 + +- 白名单在令牌之前检查:不在名单内的来源即使持有有效令牌也会被拒绝,返回 `403`,响应里带有服务器看到的来源地址,便于排查。 +- 白名单只作用于 `/mcp`,不影响网页和管理 API,填错不会把自己锁在后台之外。 +- **部署在反向代理之后**:此时直连地址永远是代理。需要把代理地址填进「可信代理」,ARTEX 才会改用 `X-Forwarded-For` 里的地址作为来源(从右往左取第一个不属于可信代理的地址)。不填可信代理时该请求头一律忽略,避免调用方伪造来源。代理需要把真实来源追加到该请求头,例如 nginx 的 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`。 +- 卡片里会显示「服务器看到的你当前的来源地址」,可用来确认可信代理是否配置正确。 + #### 权限范围与工具 令牌只能看到、调用它的权限范围内的工具。 diff --git a/db/api_tokens.go b/db/api_tokens.go index d7ea2ff..9523cd9 100644 --- a/db/api_tokens.go +++ b/db/api_tokens.go @@ -18,6 +18,9 @@ import ( // task starts real testing against a target, so exposure is an explicit choice. const SettingMCPServerEnabled = "mcp_server_enabled" +// SettingMCPServerAccess holds the /mcp source allowlist and trusted proxies as JSON. +const SettingMCPServerAccess = "mcp_server_access" + // API token scopes. A tool is callable only when the token carries its scope. const ( APIScopeRead = "read" diff --git a/server/mcp_server.go b/server/mcp_server.go index 000c021..9bedff0 100644 --- a/server/mcp_server.go +++ b/server/mcp_server.go @@ -50,6 +50,7 @@ const ( // mcpStore is the persistence the endpoint itself needs. *db.DB satisfies it. type mcpStore interface { GetBool(key string, def bool) bool + GetSetting(key string) (string, bool, error) APITokenByHash(hash string) (*db.APIToken, error) TouchAPIToken(id int64) error InsertMCPAudit(e db.MCPAuditEntry) error @@ -135,6 +136,20 @@ func (s *Server) serveMCP(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "not found") return } + // Source allowlist first: a source that is not allowed learns nothing about + // tokens. A configuration that cannot be read refuses the request. + _, access, err := loadMCPAccess(store) + if err != nil { + log.Printf("[mcp-server] 来源白名单读取失败: %v", err) + writeErr(w, http.StatusServiceUnavailable, "access configuration unavailable") + return + } + client, clientKnown := access.clientAddr(r) + if !access.permits(client, clientKnown) { + log.Printf("[mcp-server] 拒绝白名单外的来源 %s", client) + writeErr(w, http.StatusForbidden, fmt.Sprintf("source address %s is not allowed", client)) + return + } if !mcpOriginAllowed(r) { writeErr(w, http.StatusForbidden, "origin not allowed") return @@ -213,7 +228,11 @@ func (s *Server) serveMCP(w http.ResponseWriter, r *http.Request) { } writeMCPResult(w, req.ID, map[string]any{"tools": out}) case "tools/call": - s.serveMCPToolCall(w, r, store, token, req) + remote := mcpRemoteAddr(r) + if clientKnown { + remote = client.String() + } + s.serveMCPToolCall(r.Context(), w, store, token, req, remote) default: writeMCPError(w, http.StatusOK, req.ID, rpcMethodNotFound, "method not found: "+req.Method) } @@ -245,7 +264,7 @@ func (s *Server) authenticateMCP(store mcpStore, r *http.Request) (*db.APIToken, return token, "" } -func (s *Server) serveMCPToolCall(w http.ResponseWriter, r *http.Request, store mcpStore, token *db.APIToken, req mcpRPCRequest) { +func (s *Server) serveMCPToolCall(ctx context.Context, w http.ResponseWriter, store mcpStore, token *db.APIToken, req mcpRPCRequest, remote string) { var p struct { Name string `json:"name"` Arguments json.RawMessage `json:"arguments"` @@ -277,13 +296,13 @@ func (s *Server) serveMCPToolCall(w http.ResponseWriter, r *http.Request, store case args[0] != '{': result = actool.Errorf("arguments 必须是 JSON 对象") default: - result = s.runMCPTool(r.Context(), tool, token, args) + result = s.runMCPTool(ctx, tool, token, args) } text := result.Flatten() if err := store.InsertMCPAudit(db.MCPAuditEntry{ TokenID: token.ID, TokenName: token.Name, Tool: tool.Name, Args: truncateRunes(compactJSON(args), mcpAuditArgsMax), OK: !result.IsError, - Result: truncateRunes(text, mcpAuditResMax), RemoteAddr: mcpRemoteAddr(r), + Result: truncateRunes(text, mcpAuditResMax), RemoteAddr: remote, DurationMS: time.Since(start).Milliseconds(), }); err != nil { log.Printf("[mcp-server] 审计写入失败(token=%d tool=%s): %v", token.ID, tool.Name, err) @@ -335,12 +354,26 @@ func (s *Server) getMCPServerSettings(w http.ResponseWriter, r *http.Request) { if pg == nil { return } - w.Header().Set("Cache-Control", "no-store") - writeJSON(w, 200, map[string]any{ + cfg, access, err := loadMCPAccess(pg) + if err != nil { + writeErr(w, 503, "来源白名单读取失败: "+err.Error()) + return + } + out := map[string]any{ "enabled": pg.GetBool(db.SettingMCPServerEnabled, false), "path": mcpEndpointPath, "basic_auth_enabled": s.basicAuth.snapshot().Enabled, - }) + "allowed_ips": cfg.AllowedIPs, + "trusted_proxies": cfg.TrustedProxies, + } + // How this server sees the administrator's own request, resolved exactly as + // /mcp would resolve it — the value to put on the allowlist, and a quick + // check that the trusted-proxy setting is right. + if client, ok := access.clientAddr(r); ok { + out["client_ip"] = client.String() + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, 200, out) } func (s *Server) putMCPServerSettings(w http.ResponseWriter, r *http.Request) { @@ -349,17 +382,45 @@ func (s *Server) putMCPServerSettings(w http.ResponseWriter, r *http.Request) { return } var req struct { - Enabled *bool `json:"enabled"` + Enabled *bool `json:"enabled"` + AllowedIPs *[]string `json:"allowed_ips"` + TrustedProxies *[]string `json:"trusted_proxies"` } - if err := decode(r, &req); err != nil || req.Enabled == nil { - writeErr(w, 400, "enabled 为必填") + if err := decode(r, &req); err != nil || (req.Enabled == nil && req.AllowedIPs == nil && req.TrustedProxies == nil) { + writeErr(w, 400, "请求格式错误") return } - if err := pg.SetBool(db.SettingMCPServerEnabled, *req.Enabled); err != nil { - writeErr(w, 500, "保存失败: "+err.Error()) - return + if req.AllowedIPs != nil || req.TrustedProxies != nil { + cfg, _, err := loadMCPAccess(pg) + if err != nil { + // A corrupt stored value must stay replaceable from the UI. + cfg = mcpAccessConfig{} + } + if req.AllowedIPs != nil { + cfg.AllowedIPs = *req.AllowedIPs + } + if req.TrustedProxies != nil { + cfg.TrustedProxies = *req.TrustedProxies + } + cfg, _, err = normalizeMCPAccessConfig(cfg) + if err != nil { + writeErr(w, 400, err.Error()) + return + } + data, _ := json.Marshal(cfg) + if err := pg.SetSetting(db.SettingMCPServerAccess, string(data)); err != nil { + writeErr(w, 500, "保存失败: "+err.Error()) + return + } + log.Printf("[mcp-server] 来源白名单已更新: 允许 %d 条, 可信代理 %d 条", len(cfg.AllowedIPs), len(cfg.TrustedProxies)) + } + if req.Enabled != nil { + if err := pg.SetBool(db.SettingMCPServerEnabled, *req.Enabled); err != nil { + writeErr(w, 500, "保存失败: "+err.Error()) + return + } + log.Printf("[mcp-server] MCP 服务端已%s", map[bool]string{true: "开启", false: "关闭"}[*req.Enabled]) } - log.Printf("[mcp-server] MCP 服务端已%s", map[bool]string{true: "开启", false: "关闭"}[*req.Enabled]) s.getMCPServerSettings(w, r) } diff --git a/server/mcp_server_access.go b/server/mcp_server_access.go new file mode 100644 index 0000000..8976982 --- /dev/null +++ b/server/mcp_server_access.go @@ -0,0 +1,178 @@ +package server + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/netip" + "strings" + + "github.com/Autumn-27/artex/db" +) + +// 来源白名单:限制「谁」能连 /mcp。它在令牌校验之前生效,是令牌之外的第二道条件—— +// 来源地址不在白名单内的请求,即使带着有效令牌也会被拒绝。 +// +// 来源地址默认取 TCP 对端地址。部署在反向代理之后时对端永远是代理,这时需要把代理 +// 地址登记为「可信代理」,才会改用 X-Forwarded-For 里的地址;不登记就读这个头等于 +// 让任何人自报来源,白名单形同虚设。 + +// maxMCPAccessEntries bounds each list so one request cannot scan an unbounded set. +const maxMCPAccessEntries = 200 + +// mcpAccessConfig is stored as JSON under db.SettingMCPServerAccess. +type mcpAccessConfig struct { + // AllowedIPs: IPs or CIDRs allowed to call /mcp. Empty = any source. + AllowedIPs []string `json:"allowed_ips"` + // TrustedProxies: reverse proxies whose X-Forwarded-For is believed. + TrustedProxies []string `json:"trusted_proxies"` +} + +// mcpAccess is the parsed form used per request. +type mcpAccess struct { + allowed []netip.Prefix + proxies []netip.Prefix +} + +// parseMCPAccessList validates IP/CIDR entries and returns them canonicalised +// (deduplicated, input order kept) together with their parsed prefixes. +func parseMCPAccessList(entries []string) ([]string, []netip.Prefix, error) { + out := make([]string, 0, len(entries)) + prefixes := make([]netip.Prefix, 0, len(entries)) + seen := map[netip.Prefix]bool{} + for _, raw := range entries { + entry := strings.TrimSpace(raw) + if entry == "" { + continue + } + var prefix netip.Prefix + if strings.Contains(entry, "/") { + parsed, err := netip.ParsePrefix(entry) + if err != nil { + return nil, nil, fmt.Errorf("%q 不是有效的 IP 或 CIDR", entry) + } + prefix = netip.PrefixFrom(parsed.Addr().Unmap(), parsed.Bits()).Masked() + if parsed.Addr().Is4In6() { + // ::ffff:a.b.c.d/N carries the IPv4 length offset by 96 bits. + if parsed.Bits() < 96 { + return nil, nil, fmt.Errorf("%q 不是有效的 IP 或 CIDR", entry) + } + prefix = netip.PrefixFrom(parsed.Addr().Unmap(), parsed.Bits()-96).Masked() + } + } else { + addr, err := netip.ParseAddr(entry) + if err != nil { + return nil, nil, fmt.Errorf("%q 不是有效的 IP 或 CIDR", entry) + } + addr = addr.Unmap().WithZone("") + prefix = netip.PrefixFrom(addr, addr.BitLen()) + } + if !prefix.IsValid() { + return nil, nil, fmt.Errorf("%q 不是有效的 IP 或 CIDR", entry) + } + if seen[prefix] { + continue + } + seen[prefix] = true + prefixes = append(prefixes, prefix) + if prefix.IsSingleIP() { + out = append(out, prefix.Addr().String()) + } else { + out = append(out, prefix.String()) + } + } + if len(out) > maxMCPAccessEntries { + return nil, nil, fmt.Errorf("最多 %d 条", maxMCPAccessEntries) + } + return out, prefixes, nil +} + +// normalizeMCPAccessConfig validates a configuration and returns its canonical form. +func normalizeMCPAccessConfig(cfg mcpAccessConfig) (mcpAccessConfig, mcpAccess, error) { + allowed, allowedPrefixes, err := parseMCPAccessList(cfg.AllowedIPs) + if err != nil { + return cfg, mcpAccess{}, fmt.Errorf("来源白名单:%w", err) + } + proxies, proxyPrefixes, err := parseMCPAccessList(cfg.TrustedProxies) + if err != nil { + return cfg, mcpAccess{}, fmt.Errorf("可信代理:%w", err) + } + return mcpAccessConfig{AllowedIPs: allowed, TrustedProxies: proxies}, + mcpAccess{allowed: allowedPrefixes, proxies: proxyPrefixes}, nil +} + +// loadMCPAccess reads the stored configuration. An unreadable or corrupt +// configuration is an error: callers must refuse the request rather than fall +// back to "no restriction". +func loadMCPAccess(store interface { + GetSetting(string) (string, bool, error) +}) (mcpAccessConfig, mcpAccess, error) { + raw, ok, err := store.GetSetting(db.SettingMCPServerAccess) + if err != nil { + return mcpAccessConfig{}, mcpAccess{}, err + } + var cfg mcpAccessConfig + if ok && strings.TrimSpace(raw) != "" { + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return mcpAccessConfig{}, mcpAccess{}, fmt.Errorf("invalid MCP access configuration") + } + } + return normalizeMCPAccessConfig(cfg) +} + +func mcpPrefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool { + for _, prefix := range prefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +func parseMCPAddr(raw string) (netip.Addr, bool) { + addr, err := netip.ParseAddr(strings.TrimSpace(raw)) + if err != nil { + return netip.Addr{}, false + } + return addr.Unmap().WithZone(""), true +} + +// clientAddr resolves the address the request came from. The TCP peer is used +// unless it is a trusted proxy; then X-Forwarded-For is walked from the right, +// skipping further trusted proxies, and the first untrusted hop is the client. +// Entries to the left of that hop are client-supplied and never consulted. +func (a mcpAccess) clientAddr(r *http.Request) (netip.Addr, bool) { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + client, ok := parseMCPAddr(host) + if !ok { + return netip.Addr{}, false + } + if !mcpPrefixesContain(a.proxies, client) { + return client, true + } + hops := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",") + for i := len(hops) - 1; i >= 0; i-- { + hop, ok := parseMCPAddr(hops[i]) + if !ok { + // A malformed hop ends the trusted chain; keep the last address we believed. + break + } + client = hop + if !mcpPrefixesContain(a.proxies, hop) { + break + } + } + return client, true +} + +// permits reports whether the source may call /mcp. +func (a mcpAccess) permits(addr netip.Addr, known bool) bool { + if len(a.allowed) == 0 { + return true + } + return known && mcpPrefixesContain(a.allowed, addr) +} diff --git a/server/mcp_server_test.go b/server/mcp_server_test.go index 9bd2885..a7c3d00 100644 --- a/server/mcp_server_test.go +++ b/server/mcp_server_test.go @@ -21,6 +21,7 @@ type mcpMemoryStore struct { mu sync.Mutex enabled bool tokens map[string]*db.APIToken + access string // JSON stored under db.SettingMCPServerAccess touched []int64 audit []db.MCPAuditEntry } @@ -32,6 +33,13 @@ func (m *mcpMemoryStore) GetBool(key string, def bool) bool { return def } +func (m *mcpMemoryStore) GetSetting(key string) (string, bool, error) { + if key == db.SettingMCPServerAccess && m.access != "" { + return m.access, true, nil + } + return "", false, nil +} + func (m *mcpMemoryStore) APITokenByHash(hash string) (*db.APIToken, error) { return m.tokens[hash], nil } @@ -225,6 +233,135 @@ func TestMCPServerTransportRules(t *testing.T) { } } +// mcpPostFrom sends a ping from a given TCP peer with optional X-Forwarded-For. +func mcpPostFrom(s *Server, peer string, forwarded ...string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"ping"}`)) + r.RemoteAddr = peer + r.Header.Set("Authorization", "Bearer "+mcpTestReadToken) + for _, value := range forwarded { + r.Header.Add("X-Forwarded-For", value) + } + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + return w +} + +func TestMCPServerSourceAllowlist(t *testing.T) { + s, store := mcpTestServer(t) + // No allowlist: any source. + if w := mcpPostFrom(s, "203.0.113.9:4000"); w.Code != http.StatusOK { + t.Fatalf("no allowlist status=%d", w.Code) + } + + store.access = `{"allowed_ips":["198.51.100.7","10.1.0.0/16","2001:db8::/32"]}` + for peer, want := range map[string]int{ + "198.51.100.7:1": http.StatusOK, + "10.1.200.3:1": http.StatusOK, + "[2001:db8::5]:1": http.StatusOK, + "[::ffff:198.51.100.7]:1": http.StatusOK, // IPv4-mapped peer matches the IPv4 entry + "198.51.100.8:1": http.StatusForbidden, + "10.2.0.1:1": http.StatusForbidden, + "[2001:db9::1]:1": http.StatusForbidden, + "not-an-address": http.StatusForbidden, + } { + if w := mcpPostFrom(s, peer); w.Code != want { + t.Errorf("peer %s status=%d want %d", peer, w.Code, want) + } + } + // The allowlist is checked before the token: a blocked source gets 403 + // whether or not it holds a valid token, and the token is never touched. + before := len(store.touched) + r := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{}`)) + r.RemoteAddr = "198.51.100.8:1" + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "198.51.100.8") { + t.Fatalf("blocked source without token status=%d body=%s", w.Code, w.Body.String()) + } + mcpPostFrom(s, "198.51.100.8:1") + if len(store.touched) != before { + t.Fatal("token was looked up for a blocked source") + } + + // Without a trusted proxy, X-Forwarded-For is ignored: it cannot be used to + // claim an allowed address, and an allowed peer is not affected by it. + if w := mcpPostFrom(s, "203.0.113.9:1", "198.51.100.7"); w.Code != http.StatusForbidden { + t.Fatalf("spoofed X-Forwarded-For status=%d", w.Code) + } + if w := mcpPostFrom(s, "198.51.100.7:1", "203.0.113.9"); w.Code != http.StatusOK { + t.Fatalf("allowed peer with foreign X-Forwarded-For status=%d", w.Code) + } + + // Behind a trusted proxy the rightmost untrusted hop is the client. + store.access = `{"allowed_ips":["198.51.100.7"],"trusted_proxies":["127.0.0.1","10.9.0.0/24"]}` + for name, tc := range map[string]struct { + peer string + forwarded []string + want int + }{ + "client via proxy": {"127.0.0.1:1", []string{"198.51.100.7"}, http.StatusOK}, + "other client via proxy": {"127.0.0.1:1", []string{"203.0.113.9"}, http.StatusForbidden}, + "client forges leftmost entry": {"127.0.0.1:1", []string{"198.51.100.7, 203.0.113.9"}, http.StatusForbidden}, + "two trusted proxies": {"127.0.0.1:1", []string{"203.0.113.1, 198.51.100.7, 10.9.0.4"}, http.StatusOK}, + "header split across lines": {"127.0.0.1:1", []string{"198.51.100.7", "10.9.0.4"}, http.StatusOK}, + "proxy sends no header": {"127.0.0.1:1", nil, http.StatusForbidden}, + "malformed hop stops the chain": {"127.0.0.1:1", []string{"198.51.100.7, garbage, 10.9.0.4"}, http.StatusForbidden}, + "untrusted peer ignores header": {"203.0.113.9:1", []string{"198.51.100.7"}, http.StatusForbidden}, + "allowed client connects direct": {"198.51.100.7:1", nil, http.StatusOK}, + } { + if w := mcpPostFrom(s, tc.peer, tc.forwarded...); w.Code != tc.want { + t.Errorf("%s: status=%d want %d", name, w.Code, tc.want) + } + } + + // The audit records the resolved client, not the proxy. + call := httptest.NewRequest(http.MethodPost, mcpEndpointPath, strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"spawn_task","arguments":{"goal":"x"}}}`)) + call.RemoteAddr = "127.0.0.1:1" + call.Header.Set("Authorization", "Bearer "+mcpTestReadToken) + call.Header.Set("X-Forwarded-For", "198.51.100.7") + s.Handler().ServeHTTP(httptest.NewRecorder(), call) + if len(store.audit) != 1 || store.audit[0].RemoteAddr != "198.51.100.7" { + t.Fatalf("audit=%+v", store.audit) + } + + // A corrupt configuration refuses requests instead of opening the endpoint. + store.access = `{"allowed_ips":["nonsense"]}` + if w := mcpPostFrom(s, "198.51.100.7:1"); w.Code != http.StatusServiceUnavailable { + t.Fatalf("corrupt config status=%d", w.Code) + } +} + +func TestNormalizeMCPAccessConfig(t *testing.T) { + cfg, _, err := normalizeMCPAccessConfig(mcpAccessConfig{ + AllowedIPs: []string{" 10.0.0.5 ", "10.0.0.5/32", "192.168.1.77/24", "", "::ffff:1.2.3.4", "2001:DB8::1"}, + TrustedProxies: []string{"127.0.0.1"}, + }) + if err != nil { + t.Fatal(err) + } + if got := strings.Join(cfg.AllowedIPs, " "); got != "10.0.0.5 192.168.1.0/24 1.2.3.4 2001:db8::1" { + t.Fatalf("allowed=%q", got) + } + if len(cfg.TrustedProxies) != 1 || cfg.TrustedProxies[0] != "127.0.0.1" { + t.Fatalf("proxies=%v", cfg.TrustedProxies) + } + for _, bad := range []string{"example.com", "10.0.0.1/33", "10.0.0", "1.2.3.4:80", "fe80::1%eth0/64"} { + if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{AllowedIPs: []string{bad}}); err == nil { + t.Errorf("%q accepted", bad) + } + } + if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{TrustedProxies: []string{"bad"}}); err == nil || !strings.Contains(err.Error(), "可信代理") { + t.Fatalf("proxy error=%v", err) + } + tooMany := make([]string, 0, maxMCPAccessEntries+1) + for i := 0; i <= maxMCPAccessEntries; i++ { + tooMany = append(tooMany, "10.0."+strconv.Itoa(i/256)+"."+strconv.Itoa(i%256)) + } + if _, _, err := normalizeMCPAccessConfig(mcpAccessConfig{AllowedIPs: tooMany}); err == nil { + t.Fatal("oversized list accepted") + } +} + func TestMCPServerInitializeNegotiatesVersion(t *testing.T) { s, _ := mcpTestServer(t) for requested, want := range map[string]string{ @@ -343,7 +480,7 @@ func TestMCPServerPostgresEndToEnd(t *testing.T) { if err := m.SetConcurrency(false, defaultConcurrencyLimit); err != nil { t.Fatal(err) } - for _, key := range []string{db.SettingMCPServerEnabled, basicAuthKey} { + for _, key := range []string{db.SettingMCPServerEnabled, db.SettingMCPServerAccess, basicAuthKey} { old, exists, err := m.pg.GetSetting(key) if err != nil { t.Fatal(err) @@ -359,6 +496,10 @@ func TestMCPServerPostgresEndToEnd(t *testing.T) { if err := m.pg.SetSetting(basicAuthKey, `{"enabled":false}`); err != nil { t.Fatal(err) } + // Start without a source allowlist whatever the database held before. + if err := m.pg.SetSetting(db.SettingMCPServerAccess, `{}`); err != nil { + t.Fatal(err) + } s := newAdmissionTestServer(m, nil) s.jwtKey = []byte("test-only-signing-key-32-characters") m.SetTaskControlMutex(&s.concMu) @@ -587,6 +728,31 @@ func TestMCPServerPostgresEndToEnd(t *testing.T) { t.Fatalf("active-task quota not enforced: %v", err) } + // Source allowlist through the admin API: the test client is on loopback. + var settings struct { + AllowedIPs []string `json:"allowed_ips"` + ClientIP string `json:"client_ip"` + } + if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["not-an-ip"]}`, nil); code != http.StatusBadRequest { + t.Fatalf("invalid allowlist status=%d", code) + } + if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["203.0.113.0/24"]}`, &settings); code != http.StatusOK || + len(settings.AllowedIPs) != 1 || settings.ClientIP != "127.0.0.1" { + t.Fatalf("set allowlist status=%d settings=%+v", code, settings) + } + if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{}, nil); err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("source outside the allowlist still works: %v", err) + } + if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":["203.0.113.0/24","127.0.0.1"]}`, nil); code != http.StatusOK { + t.Fatalf("extend allowlist status=%d", code) + } + if _, err := mcpCallTool(t, full, "list_tasks", map[string]any{}, nil); err != nil { + t.Fatalf("allowed source rejected: %v", err) + } + if code := admin(http.MethodPut, "/api/mcp-server/settings", `{"allowed_ips":[]}`, &settings); code != http.StatusOK || len(settings.AllowedIPs) != 0 { + t.Fatalf("clear allowlist status=%d settings=%+v", code, settings) + } + // Every call above was audited, refusals included. var audit struct { Entries []db.MCPAuditEntry `json:"entries"` diff --git a/web/src/app/(main)/system/settings/_components/mcp-server-card.tsx b/web/src/app/(main)/system/settings/_components/mcp-server-card.tsx index 7565dde..2c1536a 100644 --- a/web/src/app/(main)/system/settings/_components/mcp-server-card.tsx +++ b/web/src/app/(main)/system/settings/_components/mcp-server-card.tsx @@ -30,6 +30,7 @@ import { import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; import { localizedError } from "@/i18n/errors"; import { useDateLocale } from "@/i18n/use-date-locale"; import { useLiveTranslation } from "@/i18n/use-live-translation"; @@ -98,6 +99,9 @@ export function McpServerCard() { const [tokens, setTokens] = useState([]); const [audit, setAudit] = useState([]); const [companies, setCompanies] = useState([]); + const [allowedIps, setAllowedIps] = useState(""); + const [trustedProxies, setTrustedProxies] = useState(""); + const [savingAccess, setSavingAccess] = useState(false); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -116,6 +120,8 @@ export function McpServerCard() { api.mcpServerAudit(), ]); setSettings(nextSettings); + setAllowedIps(nextSettings.allowed_ips.join("\n")); + setTrustedProxies(nextSettings.trusted_proxies.join("\n")); setTokens(nextTokens); setAudit(nextAudit); } catch (e) { @@ -146,6 +152,26 @@ export function McpServerCard() { } }; + const saveAccess = async (event: React.FormEvent) => { + event.preventDefault(); + setSavingAccess(true); + try { + const next = await api.setMcpServerAccess({ + allowed_ips: allowedIps.split("\n"), + trusted_proxies: trustedProxies.split("\n"), + }); + setSettings(next); + // Show the canonical form the server stored (trimmed, deduplicated, masked). + setAllowedIps(next.allowed_ips.join("\n")); + setTrustedProxies(next.trusted_proxies.join("\n")); + toast.success(t("settings.mcpServer.allowlistSaved")); + } catch (e) { + toast.error(t("settings.saveFailed", { message: localizedError(e, t) })); + } finally { + setSavingAccess(false); + } + }; + const openCreate = () => { setForm(emptyForm); setCreateError(null); @@ -238,6 +264,43 @@ export function McpServerCard() { ) : null} +
+ + {t("settings.mcpServer.allowlist")} +