mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-15 06:33:50 +08:00
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:
1 parent
8cf118e34d
commit
0a0158a4ab
9 files changed
+523
-19
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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;`。
|
||||
- 卡片里会显示「服务器看到的你当前的来源地址」,可用来确认可信代理是否配置正确。
|
||||
|
||||
#### 权限范围与工具
|
||||
|
||||
令牌只能看到、调用它的权限范围内的工具。
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user