Files
ARTEX-chixuweihu/server/mcp_server_access.go
T
ji ruo 0a0158a4ab 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
2026-11-14 14:32:39 +08:00

179 lines
5.7 KiB
Go

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)
}