diff --git a/Makefile b/Makefile index a5e18a37..b97404eb 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-backend build-frontend test test-backend test-frontend +.PHONY: build build-backend build-frontend test test-backend test-frontend secret-scan # 一键编译前后端 build: build-backend build-frontend @@ -20,3 +20,6 @@ test-backend: test-frontend: @pnpm --dir frontend run lint:check @pnpm --dir frontend run typecheck + +secret-scan: + @python3 tools/secret_scan.py diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 23a8d6f6..ac90f9a0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -176,6 +176,7 @@ type SecurityConfig struct { URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` CSP CSPConfig `mapstructure:"csp"` + ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"` ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` } @@ -200,6 +201,12 @@ type CSPConfig struct { Policy string `mapstructure:"policy"` } +type ProxyFallbackConfig struct { + // AllowDirectOnError 当代理初始化失败时是否允许回退直连。 + // 默认 false:避免因代理配置错误导致 IP 泄露/关联。 + AllowDirectOnError bool `mapstructure:"allow_direct_on_error"` +} + type ProxyProbeConfig struct { InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 } @@ -1047,9 +1054,20 @@ func setDefaults() { viper.SetDefault("gemini.oauth.scopes", "") viper.SetDefault("gemini.quota.policy", "") + // Security - proxy fallback + viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false) + } func (c *Config) Validate() error { + // Gemini OAuth 配置校验:client_id 与 client_secret 必须同时设置或同时留空。 + // 留空时表示使用内置的 Gemini CLI OAuth 客户端(其 client_secret 通过环境变量注入)。 + geminiClientID := strings.TrimSpace(c.Gemini.OAuth.ClientID) + geminiClientSecret := strings.TrimSpace(c.Gemini.OAuth.ClientSecret) + if (geminiClientID == "") != (geminiClientSecret == "") { + return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty") + } + if strings.TrimSpace(c.Server.FrontendURL) != "" { if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil { return fmt.Errorf("server.frontend_url invalid: %w", err) diff --git a/backend/internal/pkg/antigravity/client.go b/backend/internal/pkg/antigravity/client.go index a6279b11..c1456146 100644 --- a/backend/internal/pkg/antigravity/client.go +++ b/backend/internal/pkg/antigravity/client.go @@ -187,9 +187,14 @@ func shouldFallbackToNextURL(err error, statusCode int) bool { // ExchangeCode 用 authorization code 交换 token func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) { + clientSecret, err := getClientSecret() + if err != nil { + return nil, err + } + params := url.Values{} params.Set("client_id", ClientID) - params.Set("client_secret", ClientSecret) + params.Set("client_secret", clientSecret) params.Set("code", code) params.Set("redirect_uri", RedirectURI) params.Set("grant_type", "authorization_code") @@ -226,9 +231,14 @@ func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (* // RefreshToken 刷新 access_token func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { + clientSecret, err := getClientSecret() + if err != nil { + return nil, err + } + params := url.Values{} params.Set("client_id", ClientID) - params.Set("client_secret", ClientSecret) + params.Set("client_secret", clientSecret) params.Set("refresh_token", refreshToken) params.Set("grant_type", "refresh_token") diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index d1712c98..462879e1 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -6,10 +6,14 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "net/http" "net/url" + "os" "strings" "sync" "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) const ( @@ -20,7 +24,11 @@ const ( // Antigravity OAuth 客户端凭证 ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - ClientSecret = "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" + ClientSecret = "" + + // AntigravityOAuthClientSecretEnv 是 Antigravity OAuth client_secret 的环境变量名。 + // 出于安全原因,该值不得硬编码入库。 + AntigravityOAuthClientSecretEnv = "ANTIGRAVITY_OAUTH_CLIENT_SECRET" // 固定的 redirect_uri(用户需手动复制 code) RedirectURI = "http://localhost:8085/callback" @@ -46,6 +54,18 @@ const ( antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" ) +func getClientSecret() (string, error) { + if v := strings.TrimSpace(ClientSecret); v != "" { + return v, nil + } + if v, ok := os.LookupEnv(AntigravityOAuthClientSecretEnv); ok { + if vv := strings.TrimSpace(v); vv != "" { + return vv, nil + } + } + return "", infraerrors.Newf(http.StatusBadRequest, "ANTIGRAVITY_OAUTH_CLIENT_SECRET_MISSING", "missing antigravity oauth client_secret; set %s", AntigravityOAuthClientSecretEnv) +} + // BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致) var BaseURLs = []string{ antigravityProdBaseURL, // prod (优先) diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go index d4d52116..f85e3b97 100644 --- a/backend/internal/pkg/geminicli/constants.go +++ b/backend/internal/pkg/geminicli/constants.go @@ -38,8 +38,13 @@ const ( // GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI. // They enable the "login without creating your own OAuth client" experience, but Google may // restrict which scopes are allowed for this client. - GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" - GeminiCLIOAuthClientSecret = "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" + GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" + // GeminiCLIOAuthClientSecret is intentionally not embedded in this repository. + // If you rely on the built-in Gemini CLI OAuth client, you MUST provide its client_secret via config/env. + GeminiCLIOAuthClientSecret = "" + + // GeminiCLIOAuthClientSecretEnv is the environment variable name for the built-in client secret. + GeminiCLIOAuthClientSecretEnv = "GEMINI_CLI_OAUTH_CLIENT_SECRET" SessionTTL = 30 * time.Minute diff --git a/backend/internal/pkg/geminicli/oauth.go b/backend/internal/pkg/geminicli/oauth.go index c71e8aad..b10b5750 100644 --- a/backend/internal/pkg/geminicli/oauth.go +++ b/backend/internal/pkg/geminicli/oauth.go @@ -6,10 +6,14 @@ import ( "encoding/base64" "encoding/hex" "fmt" + "net/http" "net/url" + "os" "strings" "sync" "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) type OAuthConfig struct { @@ -164,15 +168,24 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error } // Fall back to built-in Gemini CLI OAuth client when not configured. + // SECURITY: This repo does not embed the built-in client secret; it must be provided via env. if effective.ClientID == "" && effective.ClientSecret == "" { + secret := strings.TrimSpace(GeminiCLIOAuthClientSecret) + if secret == "" { + if v, ok := os.LookupEnv(GeminiCLIOAuthClientSecretEnv); ok { + secret = strings.TrimSpace(v) + } + } + if secret == "" { + return OAuthConfig{}, infraerrors.Newf(http.StatusBadRequest, "GEMINI_CLI_OAUTH_CLIENT_SECRET_MISSING", "built-in Gemini CLI OAuth client_secret is not configured; set %s or provide a custom OAuth client", GeminiCLIOAuthClientSecretEnv) + } effective.ClientID = GeminiCLIOAuthClientID - effective.ClientSecret = GeminiCLIOAuthClientSecret + effective.ClientSecret = secret } else if effective.ClientID == "" || effective.ClientSecret == "" { - return OAuthConfig{}, fmt.Errorf("OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)") + return OAuthConfig{}, infraerrors.New(http.StatusBadRequest, "GEMINI_OAUTH_CLIENT_NOT_CONFIGURED", "OAuth client not configured: please set both client_id and client_secret (or leave both empty to use the built-in Gemini CLI client)") } - isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID && - effective.ClientSecret == GeminiCLIOAuthClientSecret + isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID if effective.Scopes == "" { // Use different default scopes based on OAuth type diff --git a/backend/internal/pkg/response/response.go b/backend/internal/pkg/response/response.go index c5b41d6e..0519c2cc 100644 --- a/backend/internal/pkg/response/response.go +++ b/backend/internal/pkg/response/response.go @@ -7,6 +7,7 @@ import ( "net/http" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/util/logredact" "github.com/gin-gonic/gin" ) @@ -78,7 +79,7 @@ func ErrorFrom(c *gin.Context, err error) bool { // Log internal errors with full details for debugging if statusCode >= 500 && c.Request != nil { - log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, err.Error()) + log.Printf("[ERROR] %s %s\n Error: %s", c.Request.Method, c.Request.URL.Path, logredact.RedactText(err.Error())) } ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata) diff --git a/backend/internal/repository/github_release_service.go b/backend/internal/repository/github_release_service.go index 03f8cc66..28efe914 100644 --- a/backend/internal/repository/github_release_service.go +++ b/backend/internal/repository/github_release_service.go @@ -18,14 +18,21 @@ type githubReleaseClient struct { downloadHTTPClient *http.Client } +type githubReleaseClientError struct { + err error +} + // NewGitHubReleaseClient 创建 GitHub Release 客户端 // proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议 -func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { +func NewGitHubReleaseClient(proxyURL string, allowDirectOnProxyError bool) service.GitHubReleaseClient { sharedClient, err := httpclient.GetClient(httpclient.Options{ Timeout: 30 * time.Second, ProxyURL: proxyURL, }) if err != nil { + if proxyURL != "" && !allowDirectOnProxyError { + return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)} + } sharedClient = &http.Client{Timeout: 30 * time.Second} } @@ -35,6 +42,9 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { ProxyURL: proxyURL, }) if err != nil { + if proxyURL != "" && !allowDirectOnProxyError { + return &githubReleaseClientError{err: fmt.Errorf("proxy client init failed and direct fallback is disabled; set security.proxy_fallback.allow_direct_on_error=true to allow fallback: %w", err)} + } downloadClient = &http.Client{Timeout: 10 * time.Minute} } @@ -44,6 +54,18 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { } } +func (c *githubReleaseClientError) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) { + return nil, c.err +} + +func (c *githubReleaseClientError) DownloadFile(ctx context.Context, url, dest string, maxSize int64) error { + return c.err +} + +func (c *githubReleaseClientError) FetchChecksumFile(ctx context.Context, url string) ([]byte, error) { + return nil, c.err +} + func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index 70715bf4..d91f654b 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -28,7 +28,7 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc // ProvideGitHubReleaseClient 创建 GitHub Release 客户端 // 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient { - return NewGitHubReleaseClient(cfg.Update.ProxyURL) + return NewGitHubReleaseClient(cfg.Update.ProxyURL, cfg.Security.ProxyFallback.AllowDirectOnError) } // ProvidePricingRemoteClient 创建定价数据远程客户端 diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go index fd2932e6..8c803531 100644 --- a/backend/internal/service/gemini_oauth_service.go +++ b/backend/internal/service/gemini_oauth_service.go @@ -81,8 +81,7 @@ func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities { // AI Studio OAuth is only enabled when the operator configures a custom OAuth client. clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID) clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret) - enabled := clientID != "" && clientSecret != "" && - (clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret) + enabled := clientID != "" && clientSecret != "" && clientID != geminicli.GeminiCLIOAuthClientID return &GeminiOAuthCapabilities{ AIStudioOAuthEnabled: enabled, @@ -151,8 +150,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64 return nil, err } - isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && - effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret + isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID // AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted). if oauthType == "ai_studio" && isBuiltinClient { @@ -485,15 +483,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch if err != nil { return nil, err } - isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && - effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret + isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID if isBuiltinClient { return nil, fmt.Errorf("AI Studio OAuth requires a custom OAuth Client. Please use an AI Studio API Key account, or configure GEMINI_OAUTH_CLIENT_ID / GEMINI_OAUTH_CLIENT_SECRET and re-authorize") } } - // code_assist always uses the built-in client and its fixed redirect URI. - if oauthType == "code_assist" { + // code_assist/google_one always uses the built-in client and its fixed redirect URI. + if oauthType == "code_assist" || oauthType == "google_one" { redirectURI = geminicli.GeminiCLIRedirectURI } diff --git a/backend/internal/service/oauth_service.go b/backend/internal/service/oauth_service.go index 15543080..e247e654 100644 --- a/backend/internal/service/oauth_service.go +++ b/backend/internal/service/oauth_service.go @@ -217,7 +217,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) ( // Ensure org_uuid is set (from step 1 if not from token response) if tokenInfo.OrgUUID == "" && orgUUID != "" { tokenInfo.OrgUUID = orgUUID - log.Printf("[OAuth] Set org_uuid from cookie auth: %s", orgUUID) + log.Printf("[OAuth] Set org_uuid from cookie auth") } return tokenInfo, nil @@ -251,16 +251,16 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" { tokenInfo.OrgUUID = tokenResp.Organization.UUID - log.Printf("[OAuth] Got org_uuid: %s", tokenInfo.OrgUUID) + log.Printf("[OAuth] Got org_uuid") } if tokenResp.Account != nil { if tokenResp.Account.UUID != "" { tokenInfo.AccountUUID = tokenResp.Account.UUID - log.Printf("[OAuth] Got account_uuid: %s", tokenInfo.AccountUUID) + log.Printf("[OAuth] Got account_uuid") } if tokenResp.Account.EmailAddress != "" { tokenInfo.EmailAddress = tokenResp.Account.EmailAddress - log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress) + log.Printf("[OAuth] Got email_address") } } diff --git a/backend/internal/util/logredact/redact.go b/backend/internal/util/logredact/redact.go index b2d2429f..492d875c 100644 --- a/backend/internal/util/logredact/redact.go +++ b/backend/internal/util/logredact/redact.go @@ -2,6 +2,7 @@ package logredact import ( "encoding/json" + "regexp" "strings" ) @@ -19,6 +20,22 @@ var defaultSensitiveKeys = map[string]struct{}{ "password": {}, } +var defaultSensitiveKeyList = []string{ + "authorization_code", + "code", + "code_verifier", + "access_token", + "refresh_token", + "id_token", + "client_secret", + "password", +} + +var ( + reGOCSPX = regexp.MustCompile(`GOCSPX-[0-9A-Za-z_-]{24,}`) + reAIza = regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`) +) + func RedactMap(input map[string]any, extraKeys ...string) map[string]any { if input == nil { return map[string]any{} @@ -48,6 +65,62 @@ func RedactJSON(raw []byte, extraKeys ...string) string { return string(encoded) } +// RedactText 对非结构化文本做轻量脱敏。 +// +// 规则: +// - 如果文本本身是 JSON,则按 RedactJSON 处理。 +// - 否则尝试对常见 key=value / key:"value" 片段做脱敏。 +// +// 注意:该函数用于日志/错误信息兜底,不保证覆盖所有格式。 +func RedactText(input string, extraKeys ...string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + + raw := []byte(input) + if json.Valid(raw) { + return RedactJSON(raw, extraKeys...) + } + + keyAlt := buildKeyAlternation(extraKeys) + // JSON-like: "access_token":"..." + reJSONLike := regexp.MustCompile(`(?i)("(?:` + keyAlt + `)"\s*:\s*")([^"]*)(")`) + // Query-like: access_token=... + reQueryLike := regexp.MustCompile(`(?i)\b((?:` + keyAlt + `))=([^&\s]+)`) + // Plain: access_token: ... / access_token = ... + rePlain := regexp.MustCompile(`(?i)\b((?:` + keyAlt + `))\b(\s*[:=]\s*)([^,\s]+)`) + + out := input + out = reGOCSPX.ReplaceAllString(out, "GOCSPX-***") + out = reAIza.ReplaceAllString(out, "AIza***") + out = reJSONLike.ReplaceAllString(out, `$1***$3`) + out = reQueryLike.ReplaceAllString(out, `$1=***`) + out = rePlain.ReplaceAllString(out, `$1$2***`) + return out +} + +func buildKeyAlternation(extraKeys []string) string { + seen := make(map[string]struct{}, len(defaultSensitiveKeyList)+len(extraKeys)) + keys := make([]string, 0, len(defaultSensitiveKeyList)+len(extraKeys)) + for _, k := range defaultSensitiveKeyList { + seen[k] = struct{}{} + keys = append(keys, regexp.QuoteMeta(k)) + } + for _, k := range extraKeys { + n := normalizeKey(k) + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + keys = append(keys, regexp.QuoteMeta(n)) + } + return strings.Join(keys, "|") +} + func buildKeySet(extraKeys []string) map[string]struct{} { keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys)) for k := range defaultSensitiveKeys { diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go index 49df015b..fc2b9bc4 100644 --- a/backend/internal/util/urlvalidator/validator.go +++ b/backend/internal/util/urlvalidator/validator.go @@ -17,6 +17,58 @@ type ValidationOptions struct { AllowPrivate bool } +// ValidateHTTPURL validates an outbound HTTP/HTTPS URL. +// +// It provides a single validation entry point that supports: +// - scheme 校验(https 或可选允许 http) +// - 可选 allowlist(支持 *.example.com 通配) +// - allow_private_hosts 策略(阻断 localhost/私网字面量 IP) +// +// 注意:DNS Rebinding 防护(解析后 IP 校验)应在实际发起请求时执行,避免 TOCTOU。 +func ValidateHTTPURL(raw string, allowInsecureHTTP bool, opts ValidationOptions) (string, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", errors.New("url is required") + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid url: %s", trimmed) + } + + scheme := strings.ToLower(parsed.Scheme) + if scheme != "https" && (!allowInsecureHTTP || scheme != "http") { + return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme) + } + + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + if host == "" { + return "", errors.New("invalid host") + } + if !opts.AllowPrivate && isBlockedHost(host) { + return "", fmt.Errorf("host is not allowed: %s", host) + } + + if port := parsed.Port(); port != "" { + num, err := strconv.Atoi(port) + if err != nil || num <= 0 || num > 65535 { + return "", fmt.Errorf("invalid port: %s", port) + } + } + + allowlist := normalizeAllowlist(opts.AllowedHosts) + if opts.RequireAllowlist && len(allowlist) == 0 { + return "", errors.New("allowlist is not configured") + } + if len(allowlist) > 0 && !isAllowedHost(host, allowlist) { + return "", fmt.Errorf("host is not allowed: %s", host) + } + + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = "" + return strings.TrimRight(parsed.String(), "/"), nil +} + func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { // 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验 trimmed := strings.TrimSpace(raw) @@ -50,38 +102,7 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { } func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "", errors.New("url is required") - } - - parsed, err := url.Parse(trimmed) - if err != nil || parsed.Scheme == "" || parsed.Host == "" { - return "", fmt.Errorf("invalid url: %s", trimmed) - } - if !strings.EqualFold(parsed.Scheme, "https") { - return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme) - } - - host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) - if host == "" { - return "", errors.New("invalid host") - } - if !opts.AllowPrivate && isBlockedHost(host) { - return "", fmt.Errorf("host is not allowed: %s", host) - } - - allowlist := normalizeAllowlist(opts.AllowedHosts) - if opts.RequireAllowlist && len(allowlist) == 0 { - return "", errors.New("allowlist is not configured") - } - if len(allowlist) > 0 && !isAllowedHost(host, allowlist) { - return "", fmt.Errorf("host is not allowed: %s", host) - } - - parsed.Path = strings.TrimRight(parsed.Path, "/") - parsed.RawPath = "" - return strings.TrimRight(parsed.String(), "/"), nil + return ValidateHTTPURL(raw, false, opts) } // ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全 diff --git a/deploy/.env.example b/deploy/.env.example index 26bb99b5..ec9150e1 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -161,6 +161,19 @@ TOTP_ENCRYPTION_KEY= # Leave unset to use default ./config.yaml #CONFIG_FILE=./config.yaml +# ----------------------------------------------------------------------------- +# Built-in OAuth Client Secrets (Optional) +# ----------------------------------------------------------------------------- +# SECURITY NOTE: +# - 本项目不会在代码仓库中内置第三方 OAuth client_secret。 +# - 如需使用“内置客户端”(而不是自建 OAuth Client),请在运行环境通过 env 注入。 +# +# Gemini CLI built-in OAuth client_secret(用于 Gemini code_assist/google_one 内置登录流) +# GEMINI_CLI_OAUTH_CLIENT_SECRET= +# +# Antigravity OAuth client_secret(用于 Antigravity OAuth 登录流) +# ANTIGRAVITY_OAUTH_CLIENT_SECRET= + # ----------------------------------------------------------------------------- # Rate Limiting (Optional) # 速率限制(可选) diff --git a/deploy/README.md b/deploy/README.md index 091d8ad7..3292e81a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -303,6 +303,10 @@ Requires your own OAuth client credentials. ```bash GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret + +# 可选:如需使用 Gemini CLI 内置 OAuth Client(Code Assist / Google One) +# 安全说明:本仓库不会内置该 client_secret,请在运行环境通过环境变量注入。 +# GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-built-in-secret ``` **Step 3: Create Account in Admin UI** @@ -430,6 +434,11 @@ If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client cre Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret ``` + 如需使用“内置 Gemini CLI OAuth Client”(Code Assist / Google One),还需要注入: + ```ini + Environment=GEMINI_CLI_OAUTH_CLIENT_SECRET=GOCSPX-your-built-in-secret + ``` + 3. Reload and restart: ```bash sudo systemctl daemon-reload diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 013e2d7d..b60082b9 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -707,10 +707,14 @@ turnstile: # 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同) gemini: oauth: - # Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio) - # Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio) - client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" - client_secret: "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" + # OAuth 客户端配置说明: + # 1) 留空 client_id/client_secret:使用 Gemini CLI 内置 OAuth Client(其 client_secret 需通过环境变量注入) + # - GEMINI_CLI_OAUTH_CLIENT_SECRET + # 2) 同时设置 client_id/client_secret:使用你自建的 OAuth Client(推荐,权限更完整) + # + # 注意:client_id 与 client_secret 必须同时为空或同时非空。 + client_id: "" + client_secret: "" # Optional scopes (space-separated). Leave empty to auto-select based on oauth_type. # 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。 scopes: "" diff --git a/deploy/docker-compose-aicodex.yml b/deploy/docker-compose-aicodex.yml index f650a60e..c8a98e87 100644 --- a/deploy/docker-compose-aicodex.yml +++ b/deploy/docker-compose-aicodex.yml @@ -125,6 +125,11 @@ services: - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + # ======================================================================= # Security Configuration (URL Allowlist) # ======================================================================= diff --git a/deploy/docker-compose-test.yml b/deploy/docker-compose-test.yml index d76dca68..5f47bc4d 100644 --- a/deploy/docker-compose-test.yml +++ b/deploy/docker-compose-test.yml @@ -104,6 +104,11 @@ services: - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + # ======================================================================= # Security Configuration (URL Allowlist) # ======================================================================= diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index e778612c..0ef397df 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -123,6 +123,11 @@ services: - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + # ======================================================================= # Security Configuration (URL Allowlist) # ======================================================================= diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index bb0041de..7676fb97 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -88,6 +88,11 @@ services: - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 4297ad0e..285d0b13 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -115,6 +115,11 @@ services: - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} + # Built-in OAuth client secrets (optional) + # SECURITY: This repo does not embed third-party client_secret. + - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} + - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + # ======================================================================= # Security Configuration (URL Allowlist) # ======================================================================= diff --git a/tools/secret_scan.py b/tools/secret_scan.py new file mode 100755 index 00000000..01058447 --- /dev/null +++ b/tools/secret_scan.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""轻量 secret scanning(CI 门禁 + 本地自检)。 + +目标:在不引入额外依赖的情况下,阻止常见敏感凭据误提交。 + +注意: +- 该脚本只扫描 git tracked files(优先)以避免误扫本地 .env。 +- 输出仅包含 file:line 与命中类型,不回显完整命中内容(避免二次泄露)。 +""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Sequence + + +@dataclass(frozen=True) +class Rule: + name: str + pattern: re.Pattern[str] + # allowlist 仅用于减少示例文档/占位符带来的误报 + allowlist: Sequence[re.Pattern[str]] + + +RULES: list[Rule] = [ + Rule( + name="google_oauth_client_secret", + # Google OAuth client_secret 常见前缀 + # 真实值通常较长;提高最小长度以避免命中文档里的占位符(例如 GOCSPX-your-client-secret)。 + pattern=re.compile(r"GOCSPX-[0-9A-Za-z_-]{24,}"), + allowlist=( + re.compile(r"GOCSPX-your-"), + re.compile(r"GOCSPX-REDACTED"), + ), + ), + Rule( + name="google_api_key", + # Gemini / Google API Key + # 典型格式:AIza + 35 位字符。占位符如 'AIza...' 不会匹配。 + pattern=re.compile(r"AIza[0-9A-Za-z_-]{35}"), + allowlist=( + re.compile(r"AIza\.{3}"), + re.compile(r"AIza-your-"), + re.compile(r"AIza-REDACTED"), + ), + ), +] + + +def iter_git_files(repo_root: Path) -> list[Path]: + try: + out = subprocess.check_output( + ["git", "ls-files"], cwd=repo_root, stderr=subprocess.DEVNULL, text=True + ) + except Exception: + return [] + files: list[Path] = [] + for line in out.splitlines(): + p = (repo_root / line).resolve() + if p.is_file(): + files.append(p) + return files + + +def iter_walk_files(repo_root: Path) -> Iterable[Path]: + for dirpath, _dirnames, filenames in os.walk(repo_root): + if "/.git/" in dirpath.replace("\\", "/"): + continue + for name in filenames: + yield Path(dirpath) / name + + +def should_skip(path: Path, repo_root: Path) -> bool: + rel = path.relative_to(repo_root).as_posix() + # 本地环境文件一般不应入库;若误入库也会被 git ls-files 扫出来。 + # 这里仍跳过一些明显不该扫描的二进制。 + if any(rel.endswith(s) for s in (".png", ".jpg", ".jpeg", ".gif", ".pdf", ".zip")): + return True + if rel.startswith("backend/bin/"): + return True + return False + + +def scan_file(path: Path, repo_root: Path) -> list[tuple[str, int]]: + try: + raw = path.read_bytes() + except Exception: + return [] + + # 尝试按 utf-8 解码,失败则当二进制跳过 + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + return [] + + findings: list[tuple[str, int]] = [] + lines = text.splitlines() + for idx, line in enumerate(lines, start=1): + for rule in RULES: + if not rule.pattern.search(line): + continue + if any(allow.search(line) for allow in rule.allowlist): + continue + rel = path.relative_to(repo_root).as_posix() + findings.append((f"{rel}:{idx} ({rule.name})", idx)) + return findings + + +def main(argv: Sequence[str]) -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repo-root", + default=str(Path(__file__).resolve().parents[1]), + help="仓库根目录(默认:脚本上两级目录)", + ) + args = parser.parse_args(argv) + + repo_root = Path(args.repo_root).resolve() + files = iter_git_files(repo_root) + if not files: + files = list(iter_walk_files(repo_root)) + + problems: list[str] = [] + for f in files: + if should_skip(f, repo_root): + continue + for msg, _line in scan_file(f, repo_root): + problems.append(msg) + + if problems: + sys.stderr.write("Secret scan FAILED. Potential secrets detected:\n") + for p in problems: + sys.stderr.write(f"- {p}\n") + sys.stderr.write("\n请移除/改为环境变量注入,或使用明确的占位符(例如 GOCSPX-your-client-secret)。\n") + return 1 + + print("Secret scan OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) +