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
This commit is contained in:
ji ruo committed 2026-11-14 14:32:39 +08:00
1 parent 8cf118e34d
commit 0a0158a4ab
9 files changed
+523 -19

No files matched your search

+1 -1
View File
@@ -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
+10 -1
View File
@@ -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;`。
- 卡片里会显示「服务器看到的你当前的来源地址」,可用来确认可信代理是否配置正确。
#### 权限范围与工具
令牌只能看到、调用它的权限范围内的工具。
+3
View File
@@ -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"
+75 -14
View File
@@ -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)
}
+178
View File
@@ -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)
}
+167 -1
View File
@@ -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"`
@@ -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<McpServerToken[]>([]);
const [audit, setAudit] = useState<McpServerAuditEntry[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [allowedIps, setAllowedIps] = useState("");
const [trustedProxies, setTrustedProxies] = useState("");
const [savingAccess, setSavingAccess] = useState(false);
const [error, setError] = useState<unknown>(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}
</Field>
<form onSubmit={saveAccess} className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="mcp-server-allowlist">{t("settings.mcpServer.allowlist")}</FieldLabel>
<Textarea
id="mcp-server-allowlist"
rows={3}
className="font-mono text-xs"
value={allowedIps}
disabled={!settings || savingAccess}
placeholder={t("settings.mcpServer.allowlistPlaceholder")}
onChange={(e) => setAllowedIps(e.target.value)}
/>
<FieldDescription>{t("settings.mcpServer.allowlistHelp")}</FieldDescription>
{settings?.client_ip ? (
<FieldDescription>{t("settings.mcpServer.clientIp", { ip: settings.client_ip })}</FieldDescription>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="mcp-server-proxies">{t("settings.mcpServer.trustedProxies")}</FieldLabel>
<Textarea
id="mcp-server-proxies"
rows={2}
className="font-mono text-xs"
value={trustedProxies}
disabled={!settings || savingAccess}
placeholder={t("settings.mcpServer.trustedProxiesPlaceholder")}
onChange={(e) => setTrustedProxies(e.target.value)}
/>
<FieldDescription>{t("settings.mcpServer.trustedProxiesHelp")}</FieldDescription>
</Field>
<div>
<Button type="submit" variant="outline" disabled={!settings || savingAccess}>
{savingAccess ? t("settings.saving") : t("settings.mcpServer.saveAllowlist")}
</Button>
</div>
</form>
{error ? (
<div className="flex items-center gap-2">
<p role="alert" className="text-destructive text-sm">
+20 -2
View File
@@ -115,8 +115,8 @@ export const settingsMessages = {
"basic auth authentication sign-in login username password",
],
"settings.keywords.mcpServer": [
"MCP 服务端 访问令牌 token 智能体 编排 外部 bot 审计",
"mcp server access token agent orchestration external bot audit",
"MCP 服务端 访问令牌 token 智能体 编排 外部 bot 审计 白名单 来源 IP",
"mcp server access token agent orchestration external bot audit allowlist whitelist source ip",
],
"settings.trafficCapture": ["流量捕获", "Traffic capture"],
"settings.trafficCaptureHelp": [
@@ -506,6 +506,24 @@ export const settingsMessages = {
"当前已启用 HTTP Basic Auth,/mcp 仍可直接凭访问令牌访问。",
"HTTP Basic Auth is currently enabled; /mcp remains reachable with an access token alone.",
],
"settings.mcpServer.allowlist": ["来源白名单", "Source allowlist"],
"settings.mcpServer.allowlistHelp": [
"只有这些来源地址能调用 /mcp,每行一个 IP 或 CIDR(如 203.0.113.7 或 10.0.0.0/8)。留空 = 不限制来源。白名单在令牌之前检查:不在名单内的来源即使持有有效令牌也会被拒绝(403)。",
"Only these source addresses may call /mcp. One IP or CIDR per line (for example 203.0.113.7 or 10.0.0.0/8). Leave empty for no source restriction. The allowlist is checked before the token: a source that is not listed is rejected (403) even with a valid token.",
],
"settings.mcpServer.allowlistPlaceholder": ["留空 = 不限制来源", "Empty = no source restriction"],
"settings.mcpServer.trustedProxies": ["可信代理", "Trusted proxies"],
"settings.mcpServer.trustedProxiesHelp": [
"ARTEX 部署在反向代理(nginx 等)之后时,填写代理的地址,每行一个 IP 或 CIDR;来自这些地址的请求改用 X-Forwarded-For 里的地址作为来源。不填则一律以直连地址为准——此时经代理进来的请求来源都是代理本身。不要填不受你控制的地址,否则对方可以伪造来源。",
"If ARTEX runs behind a reverse proxy (nginx and the like), list the proxy addresses here, one IP or CIDR per line; requests from them use the address in X-Forwarded-For as the source. Left empty, the direct connection address is always used, so every request arriving through a proxy appears to come from the proxy itself. Never list addresses you do not control, or their owner can forge the source.",
],
"settings.mcpServer.trustedProxiesPlaceholder": ["没有反向代理则留空", "Leave empty without a reverse proxy"],
"settings.mcpServer.clientIp": [
"服务器看到的你当前的来源地址:{{ip}}",
"Your current source address as the server sees it: {{ip}}",
],
"settings.mcpServer.saveAllowlist": ["保存来源白名单", "Save source allowlist"],
"settings.mcpServer.allowlistSaved": ["来源白名单已保存", "Source allowlist saved"],
"settings.mcpServer.copy": ["复制", "Copy"],
"settings.mcpServer.copied": ["已复制", "Copied"],
"settings.mcpServer.tokens": ["访问令牌", "Access tokens"],
+6
View File
@@ -169,6 +169,10 @@ export interface McpServerSettings {
enabled: boolean;
path: string;
basic_auth_enabled: boolean;
allowed_ips: string[];
trusted_proxies: string[];
// 服务端按 /mcp 的同一套规则解析出的、当前管理员请求的来源地址。
client_ip?: string;
}
export interface McpServerToken {
@@ -1148,6 +1152,8 @@ export const api = {
// ---- MCP 服务端(ARTEX 对外暴露的 /mcp):开关、访问令牌、调用审计 ----
mcpServerSettings: () => get<McpServerSettings>("/mcp-server/settings"),
setMcpServerEnabled: (enabled: boolean) => put<McpServerSettings>("/mcp-server/settings", { enabled }),
setMcpServerAccess: (access: { allowed_ips: string[]; trusted_proxies: string[] }) =>
put<McpServerSettings>("/mcp-server/settings", access),
mcpServerTokens: () => get<{ tokens: McpServerToken[] }>("/mcp-server/tokens").then((r) => arr(r.tokens)),
// 明文令牌只在创建响应里返回这一次。
createMcpServerToken: (input: McpServerTokenInput) =>