Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ deploy/docker-compose.override.yml
vite.config.js
docs/*
!docs/admin-api-keys.md
!docs/mcp.md
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
Expand Down
9 changes: 6 additions & 3 deletions backend/cmd/server/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
github.com/imroc/req/v3 v3.59.0
github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.22.0
Expand Down Expand Up @@ -128,6 +129,7 @@ require (
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
Expand Down Expand Up @@ -167,6 +169,8 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.0 // indirect
Expand All @@ -187,6 +191,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
Expand All @@ -201,6 +206,7 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
golang.org/x/time v0.12.0 // indirect
Expand Down
12 changes: 12 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
Expand Down Expand Up @@ -349,6 +351,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
Expand Down Expand Up @@ -405,6 +409,10 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
Expand Down Expand Up @@ -498,6 +506,8 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
Expand Down Expand Up @@ -604,6 +614,8 @@ golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
55 changes: 55 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"fmt"
"log/slog"
"math"
"net/netip"
"net/textproto"
"net/url"
"os"
"strconv"
"strings"
"sync/atomic"
"time"
Expand Down Expand Up @@ -69,6 +71,7 @@ type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
CORS CORSConfig `mapstructure:"cors"`
MCP MCPConfig `mapstructure:"mcp"`
Security SecurityConfig `mapstructure:"security"`
Billing BillingConfig `mapstructure:"billing"`
Turnstile TurnstileConfig `mapstructure:"turnstile"`
Expand Down Expand Up @@ -116,6 +119,54 @@ type SimpleModeConfig struct {
AutoCreateDefaultGroups bool `mapstructure:"auto_create_default_groups" yaml:"auto_create_default_groups"`
}

// MCPConfig fixes the public resource URL used by OAuth discovery and tokens.
// Setting it is also the explicit opt-in that enables MCP.
type MCPConfig struct {
PublicURL string `mapstructure:"public_url"`
}

// Enabled reports whether the operator opted in to the management MCP endpoint.
func (c MCPConfig) Enabled() bool { return strings.TrimSpace(c.PublicURL) != "" }

// Validate normalizes the configured resource URL without trusting request headers.
// An empty URL keeps MCP and its OAuth endpoints disabled.
func (c *MCPConfig) Validate() error {
raw := strings.TrimSpace(c.PublicURL)
if raw == "" {
c.PublicURL = ""
return nil
}
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("mcp.public_url invalid: %w", err)
}
if !u.IsAbs() || u.Hostname() == "" || !httpguts.ValidHostHeader(u.Host) || strings.ContainsAny(u.Host, "*%") {
return fmt.Errorf("mcp.public_url must be an absolute URL with a valid host")
}
if u.User != nil || u.RawQuery != "" || u.ForceQuery || strings.Contains(raw, "#") {
return fmt.Errorf("mcp.public_url must not include userinfo, query, or fragment")
}
if u.Path != "/api/v1/mcp" || u.EscapedPath() != "/api/v1/mcp" {
return fmt.Errorf("mcp.public_url must have the exact path /api/v1/mcp without a trailing slash")
}
if port := u.Port(); port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return fmt.Errorf("mcp.public_url must have a valid port")
}
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
host := u.Hostname()
addr, _ := netip.ParseAddr(host)
localDevelopment := host == "localhost" || addr.IsLoopback()
if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) {
return fmt.Errorf("mcp.public_url must use HTTPS (HTTP is allowed only for localhost or loopback IPs)")
}
c.PublicURL = u.String()
return nil
}

// PluginConfig 控制管理员手动上传的本地进程插件。
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
type PluginConfig struct {
Expand Down Expand Up @@ -2086,6 +2137,7 @@ func setDefaults() {
// CORS
viper.SetDefault("cors.allowed_origins", []string{})
viper.SetDefault("cors.allow_credentials", true)
viper.SetDefault("mcp.public_url", "")

// WebAuthn / Passkeys are opt-in because every deployment must explicitly
// declare its relying-party domain and trusted browser origins.
Expand Down Expand Up @@ -2862,6 +2914,9 @@ func (c *Config) Validate() error {
return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty")
}

if err := c.MCP.Validate(); err != nil {
return err
}
if strings.TrimSpace(c.Server.FrontendURL) != "" {
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
return fmt.Errorf("server.frontend_url invalid: %w", err)
Expand Down
64 changes: 64 additions & 0 deletions backend/internal/config/mcp_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package config

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestMCPConfigValidatePublicURL(t *testing.T) {
for _, tc := range []struct {
name, value, want string
}{
{"unset", "", ""},
{"blank", " ", ""},
{"https", "https://api.example.com/api/v1/mcp", "https://api.example.com/api/v1/mcp"},
{"normalize", " HTTPS://API.Example.COM:8443/api/v1/mcp ", "https://api.example.com:8443/api/v1/mcp"},
{"localhost", "http://localhost:8080/api/v1/mcp", "http://localhost:8080/api/v1/mcp"},
{"loopback IPv4", "http://127.0.0.2:8080/api/v1/mcp", "http://127.0.0.2:8080/api/v1/mcp"},
{"loopback IPv6", "http://[::1]:8080/api/v1/mcp", "http://[::1]:8080/api/v1/mcp"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := MCPConfig{PublicURL: tc.value}
require.NoError(t, cfg.Validate())
require.Equal(t, tc.want, cfg.PublicURL)
})
}
for _, value := range []string{
"/api/v1/mcp", "//example.com/api/v1/mcp", "https:///api/v1/mcp",
"https://:443/api/v1/mcp", "https://*.example.com/api/v1/mcp",
"http://example.com/api/v1/mcp", "http://localhost.example.com/api/v1/mcp",
"http://10.0.0.1/api/v1/mcp", "http://[::2]/api/v1/mcp",
"ftp://localhost/api/v1/mcp", "https://example.com/api/v1/mcp/",
"https://example.com/mcp", "https://example.com/api/v1/%6dcp",
"https://example.com/api/v1/mcp?", "https://example.com/api/v1/mcp?x=1",
"https://example.com/api/v1/mcp#", "https://example.com/api/v1/mcp#fragment",
"https://user:password@example.com/api/v1/mcp", "https://user@example.com/api/v1/mcp",
"https://example.com:0/api/v1/mcp", "https://example.com:65536/api/v1/mcp",
"https://example.com:invalid/api/v1/mcp", "http://[::1%25eth0]/api/v1/mcp",
} {
t.Run(value, func(t *testing.T) {
cfg := MCPConfig{PublicURL: value}
require.ErrorContains(t, cfg.Validate(), "mcp.public_url")
})
}
}

func TestLoadMCPPublicURLFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("MCP_PUBLIC_URL", "HTTPS://MCP.Example.COM/api/v1/mcp")
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "https://mcp.example.com/api/v1/mcp", cfg.MCP.PublicURL)

cfg.MCP.PublicURL = "http://example.com/api/v1/mcp"
require.ErrorContains(t, cfg.Validate(), "mcp.public_url must use HTTPS")
}

func TestLoadMCPPublicURLDefault(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("MCP_PUBLIC_URL", "")
cfg, err := Load()
require.NoError(t, err)
require.Empty(t, cfg.MCP.PublicURL)
}
8 changes: 4 additions & 4 deletions backend/internal/handler/admin/audit_log_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,13 @@ type auditLogClearRequest struct {
// 安全要求(与需求对齐):
// 1. 每次清空都必须现场验证 TOTP 码(不复用 step-up sudo 窗口)
// 2. 未启用 TOTP 的管理员不允许清空
// 3. admin API key(机器凭证)不允许清空
// 3. 机器凭证(admin API key、MCP OAuth 等非浏览器会话)不允许清空
// 4. 清空完成后同步写入一条留痕记录(操作者、IP、UA、删除行数)
func (h *AuditLogHandler) Clear(c *gin.Context) {
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
if reason, machine := middleware.MachineCredentialReason(c); machine {
response.ErrorWithDetails(c, http.StatusForbidden,
"Admin API key cannot clear audit logs; a two-factor verified admin session is required",
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
"Machine credentials cannot clear audit logs; a two-factor verified admin session is required",
reason, nil)
return
}

Expand Down
8 changes: 4 additions & 4 deletions backend/internal/handler/admin/setting_handler_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,13 @@ type UpdateSettingsRequest struct {
// UpdateSettings 更新系统设置
// PUT /api/v1/admin/settings
// ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件:
// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。
// 必须是真人管理员会话(admin API key、MCP OAuth 等机器凭证无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。
// 校验失败时写入错误响应并返回 false。
func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool {
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
if reason, machine := middleware.MachineCredentialReason(c); machine {
response.ErrorWithDetails(c, http.StatusForbidden,
"Admin API key cannot enable step-up verification; use an admin session with TOTP enabled",
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
"Machine credentials cannot enable step-up verification; use an admin session with TOTP enabled",
reason, nil)
return false
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
Expand Down
Loading
Loading