Commit d7011163 authored by yangjianbo's avatar yangjianbo
Browse files

fix: 修复代码审核发现的安全和质量问题



安全修复(P0):
- 移除硬编码的 OAuth client_secret(Antigravity、Gemini CLI),
  改为通过环境变量注入(ANTIGRAVITY_OAUTH_CLIENT_SECRET、
  GEMINI_CLI_OAUTH_CLIENT_SECRET)
- 新增 logredact.RedactText() 对非结构化文本做敏感信息脱敏,
  覆盖 GOCSPX-*/AIza* 令牌和常见 key=value 模式
- 日志中不再打印 org_uuid、account_uuid、email_address 等敏感值

安全修复(P1):
- URL 验证增强:新增 ValidateHTTPURL 统一入口,支持 allowlist 和
  私网地址阻断(localhost/内网 IP)
- 代理回退安全:代理初始化失败时默认阻止直连回退,防止 IP 泄露,
  可通过 security.proxy_fallback.allow_direct_on_error 显式开启
- Gemini OAuth 配置校验:client_id 与 client_secret 必须同时
  设置或同时留空

其他改进:
- 新增 tools/secret_scan.py 密钥扫描工具和 Makefile secret-scan 目标
- 更新所有 docker-compose 和部署配置,传递 OAuth secret 环境变量
- google_one OAuth 类型使用固定 redirectURI,与 code_assist 对齐
Co-Authored-By: default avatarClaude Opus 4.6 <noreply@anthropic.com>
parent fc8a39e0
.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 build: build-backend build-frontend
...@@ -20,3 +20,6 @@ test-backend: ...@@ -20,3 +20,6 @@ test-backend:
test-frontend: test-frontend:
@pnpm --dir frontend run lint:check @pnpm --dir frontend run lint:check
@pnpm --dir frontend run typecheck @pnpm --dir frontend run typecheck
secret-scan:
@python3 tools/secret_scan.py
...@@ -176,6 +176,7 @@ type SecurityConfig struct { ...@@ -176,6 +176,7 @@ type SecurityConfig struct {
URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"` URLAllowlist URLAllowlistConfig `mapstructure:"url_allowlist"`
ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"` ResponseHeaders ResponseHeaderConfig `mapstructure:"response_headers"`
CSP CSPConfig `mapstructure:"csp"` CSP CSPConfig `mapstructure:"csp"`
ProxyFallback ProxyFallbackConfig `mapstructure:"proxy_fallback"`
ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"` ProxyProbe ProxyProbeConfig `mapstructure:"proxy_probe"`
} }
...@@ -200,6 +201,12 @@ type CSPConfig struct { ...@@ -200,6 +201,12 @@ type CSPConfig struct {
Policy string `mapstructure:"policy"` Policy string `mapstructure:"policy"`
} }
type ProxyFallbackConfig struct {
// AllowDirectOnError 当代理初始化失败时是否允许回退直连。
// 默认 false:避免因代理配置错误导致 IP 泄露/关联。
AllowDirectOnError bool `mapstructure:"allow_direct_on_error"`
}
type ProxyProbeConfig struct { type ProxyProbeConfig struct {
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证 InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
} }
...@@ -1047,9 +1054,20 @@ func setDefaults() { ...@@ -1047,9 +1054,20 @@ func setDefaults() {
viper.SetDefault("gemini.oauth.scopes", "") viper.SetDefault("gemini.oauth.scopes", "")
viper.SetDefault("gemini.quota.policy", "") viper.SetDefault("gemini.quota.policy", "")
// Security - proxy fallback
viper.SetDefault("security.proxy_fallback.allow_direct_on_error", false)
} }
func (c *Config) Validate() error { 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 strings.TrimSpace(c.Server.FrontendURL) != "" {
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil { if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
return fmt.Errorf("server.frontend_url invalid: %w", err) return fmt.Errorf("server.frontend_url invalid: %w", err)
......
...@@ -187,9 +187,14 @@ func shouldFallbackToNextURL(err error, statusCode int) bool { ...@@ -187,9 +187,14 @@ func shouldFallbackToNextURL(err error, statusCode int) bool {
// ExchangeCode 用 authorization code 交换 token // ExchangeCode 用 authorization code 交换 token
func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) { 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 := url.Values{}
params.Set("client_id", ClientID) params.Set("client_id", ClientID)
params.Set("client_secret", ClientSecret) params.Set("client_secret", clientSecret)
params.Set("code", code) params.Set("code", code)
params.Set("redirect_uri", RedirectURI) params.Set("redirect_uri", RedirectURI)
params.Set("grant_type", "authorization_code") params.Set("grant_type", "authorization_code")
...@@ -226,9 +231,14 @@ func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (* ...@@ -226,9 +231,14 @@ func (c *Client) ExchangeCode(ctx context.Context, code, codeVerifier string) (*
// RefreshToken 刷新 access_token // RefreshToken 刷新 access_token
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) {
clientSecret, err := getClientSecret()
if err != nil {
return nil, err
}
params := url.Values{} params := url.Values{}
params.Set("client_id", ClientID) params.Set("client_id", ClientID)
params.Set("client_secret", ClientSecret) params.Set("client_secret", clientSecret)
params.Set("refresh_token", refreshToken) params.Set("refresh_token", refreshToken)
params.Set("grant_type", "refresh_token") params.Set("grant_type", "refresh_token")
......
...@@ -6,10 +6,14 @@ import ( ...@@ -6,10 +6,14 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
) )
const ( const (
...@@ -20,7 +24,11 @@ const ( ...@@ -20,7 +24,11 @@ const (
// Antigravity OAuth 客户端凭证 // Antigravity OAuth 客户端凭证
ClientID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" 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) // 固定的 redirect_uri(用户需手动复制 code)
RedirectURI = "http://localhost:8085/callback" RedirectURI = "http://localhost:8085/callback"
...@@ -46,6 +54,18 @@ const ( ...@@ -46,6 +54,18 @@ const (
antigravityDailyBaseURL = "https://daily-cloudcode-pa.sandbox.googleapis.com" 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 保持一致) // BaseURLs 定义 Antigravity API 端点(与 Antigravity-Manager 保持一致)
var BaseURLs = []string{ var BaseURLs = []string{
antigravityProdBaseURL, // prod (优先) antigravityProdBaseURL, // prod (优先)
......
...@@ -39,7 +39,12 @@ const ( ...@@ -39,7 +39,12 @@ const (
// They enable the "login without creating your own OAuth client" experience, but Google may // They enable the "login without creating your own OAuth client" experience, but Google may
// restrict which scopes are allowed for this client. // restrict which scopes are allowed for this client.
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
GeminiCLIOAuthClientSecret = "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" // 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 SessionTTL = 30 * time.Minute
......
...@@ -6,10 +6,14 @@ import ( ...@@ -6,10 +6,14 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os"
"strings" "strings"
"sync" "sync"
"time" "time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
) )
type OAuthConfig struct { type OAuthConfig struct {
...@@ -164,15 +168,24 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error ...@@ -164,15 +168,24 @@ func EffectiveOAuthConfig(cfg OAuthConfig, oauthType string) (OAuthConfig, error
} }
// Fall back to built-in Gemini CLI OAuth client when not configured. // 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 == "" { 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.ClientID = GeminiCLIOAuthClientID
effective.ClientSecret = GeminiCLIOAuthClientSecret effective.ClientSecret = secret
} else if effective.ClientID == "" || effective.ClientSecret == "" { } 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 && isBuiltinClient := effective.ClientID == GeminiCLIOAuthClientID
effective.ClientSecret == GeminiCLIOAuthClientSecret
if effective.Scopes == "" { if effective.Scopes == "" {
// Use different default scopes based on OAuth type // Use different default scopes based on OAuth type
......
...@@ -7,6 +7,7 @@ import ( ...@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -78,7 +79,7 @@ func ErrorFrom(c *gin.Context, err error) bool { ...@@ -78,7 +79,7 @@ func ErrorFrom(c *gin.Context, err error) bool {
// Log internal errors with full details for debugging // Log internal errors with full details for debugging
if statusCode >= 500 && c.Request != nil { 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) ErrorWithDetails(c, statusCode, status.Message, status.Reason, status.Metadata)
......
...@@ -18,14 +18,21 @@ type githubReleaseClient struct { ...@@ -18,14 +18,21 @@ type githubReleaseClient struct {
downloadHTTPClient *http.Client downloadHTTPClient *http.Client
} }
type githubReleaseClientError struct {
err error
}
// NewGitHubReleaseClient 创建 GitHub Release 客户端 // NewGitHubReleaseClient 创建 GitHub Release 客户端
// proxyURL 为空时直连 GitHub,支持 http/https/socks5/socks5h 协议 // 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{ sharedClient, err := httpclient.GetClient(httpclient.Options{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
ProxyURL: proxyURL, ProxyURL: proxyURL,
}) })
if err != nil { 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} sharedClient = &http.Client{Timeout: 30 * time.Second}
} }
...@@ -35,6 +42,9 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { ...@@ -35,6 +42,9 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient {
ProxyURL: proxyURL, ProxyURL: proxyURL,
}) })
if err != nil { 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} downloadClient = &http.Client{Timeout: 10 * time.Minute}
} }
...@@ -44,6 +54,18 @@ func NewGitHubReleaseClient(proxyURL string) service.GitHubReleaseClient { ...@@ -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) { func (c *githubReleaseClient) FetchLatestRelease(ctx context.Context, repo string) (*service.GitHubRelease, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
......
...@@ -28,7 +28,7 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc ...@@ -28,7 +28,7 @@ func ProvideConcurrencyCache(rdb *redis.Client, cfg *config.Config) service.Conc
// ProvideGitHubReleaseClient 创建 GitHub Release 客户端 // ProvideGitHubReleaseClient 创建 GitHub Release 客户端
// 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub // 从配置中读取代理设置,支持国内服务器通过代理访问 GitHub
func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient { func ProvideGitHubReleaseClient(cfg *config.Config) service.GitHubReleaseClient {
return NewGitHubReleaseClient(cfg.Update.ProxyURL) return NewGitHubReleaseClient(cfg.Update.ProxyURL, cfg.Security.ProxyFallback.AllowDirectOnError)
} }
// ProvidePricingRemoteClient 创建定价数据远程客户端 // ProvidePricingRemoteClient 创建定价数据远程客户端
......
...@@ -81,8 +81,7 @@ func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities { ...@@ -81,8 +81,7 @@ func (s *GeminiOAuthService) GetOAuthConfig() *GeminiOAuthCapabilities {
// AI Studio OAuth is only enabled when the operator configures a custom OAuth client. // AI Studio OAuth is only enabled when the operator configures a custom OAuth client.
clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID) clientID := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientID)
clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret) clientSecret := strings.TrimSpace(s.cfg.Gemini.OAuth.ClientSecret)
enabled := clientID != "" && clientSecret != "" && enabled := clientID != "" && clientSecret != "" && clientID != geminicli.GeminiCLIOAuthClientID
(clientID != geminicli.GeminiCLIOAuthClientID || clientSecret != geminicli.GeminiCLIOAuthClientSecret)
return &GeminiOAuthCapabilities{ return &GeminiOAuthCapabilities{
AIStudioOAuthEnabled: enabled, AIStudioOAuthEnabled: enabled,
...@@ -151,8 +150,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64 ...@@ -151,8 +150,7 @@ func (s *GeminiOAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64
return nil, err return nil, err
} }
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
// AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted). // AI Studio OAuth requires a user-provided OAuth client (built-in Gemini CLI client is scope-restricted).
if oauthType == "ai_studio" && isBuiltinClient { if oauthType == "ai_studio" && isBuiltinClient {
...@@ -485,15 +483,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch ...@@ -485,15 +483,14 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
if err != nil { if err != nil {
return nil, err return nil, err
} }
isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID && isBuiltinClient := effectiveCfg.ClientID == geminicli.GeminiCLIOAuthClientID
effectiveCfg.ClientSecret == geminicli.GeminiCLIOAuthClientSecret
if isBuiltinClient { 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") 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. // code_assist/google_one always uses the built-in client and its fixed redirect URI.
if oauthType == "code_assist" { if oauthType == "code_assist" || oauthType == "google_one" {
redirectURI = geminicli.GeminiCLIRedirectURI redirectURI = geminicli.GeminiCLIRedirectURI
} }
......
...@@ -217,7 +217,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) ( ...@@ -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) // Ensure org_uuid is set (from step 1 if not from token response)
if tokenInfo.OrgUUID == "" && orgUUID != "" { if tokenInfo.OrgUUID == "" && orgUUID != "" {
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 return tokenInfo, nil
...@@ -251,16 +251,16 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif ...@@ -251,16 +251,16 @@ func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerif
if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" { if tokenResp.Organization != nil && tokenResp.Organization.UUID != "" {
tokenInfo.OrgUUID = 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 != nil {
if tokenResp.Account.UUID != "" { if tokenResp.Account.UUID != "" {
tokenInfo.AccountUUID = 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 != "" { if tokenResp.Account.EmailAddress != "" {
tokenInfo.EmailAddress = tokenResp.Account.EmailAddress tokenInfo.EmailAddress = tokenResp.Account.EmailAddress
log.Printf("[OAuth] Got email_address: %s", tokenInfo.EmailAddress) log.Printf("[OAuth] Got email_address")
} }
} }
......
...@@ -2,6 +2,7 @@ package logredact ...@@ -2,6 +2,7 @@ package logredact
import ( import (
"encoding/json" "encoding/json"
"regexp"
"strings" "strings"
) )
...@@ -19,6 +20,22 @@ var defaultSensitiveKeys = map[string]struct{}{ ...@@ -19,6 +20,22 @@ var defaultSensitiveKeys = map[string]struct{}{
"password": {}, "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 { func RedactMap(input map[string]any, extraKeys ...string) map[string]any {
if input == nil { if input == nil {
return map[string]any{} return map[string]any{}
...@@ -48,6 +65,62 @@ func RedactJSON(raw []byte, extraKeys ...string) string { ...@@ -48,6 +65,62 @@ func RedactJSON(raw []byte, extraKeys ...string) string {
return string(encoded) 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{} { func buildKeySet(extraKeys []string) map[string]struct{} {
keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys)) keys := make(map[string]struct{}, len(defaultSensitiveKeys)+len(extraKeys))
for k := range defaultSensitiveKeys { for k := range defaultSensitiveKeys {
......
...@@ -17,8 +17,15 @@ type ValidationOptions struct { ...@@ -17,8 +17,15 @@ type ValidationOptions struct {
AllowPrivate bool AllowPrivate bool
} }
func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { // ValidateHTTPURL validates an outbound HTTP/HTTPS URL.
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验 //
// 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) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
return "", errors.New("url is required") return "", errors.New("url is required")
...@@ -34,10 +41,13 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { ...@@ -34,10 +41,13 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme) return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
} }
host := strings.TrimSpace(parsed.Hostname()) host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
if host == "" { if host == "" {
return "", errors.New("invalid 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 != "" { if port := parsed.Port(); port != "" {
num, err := strconv.Atoi(port) num, err := strconv.Atoi(port)
...@@ -46,10 +56,21 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) { ...@@ -46,10 +56,21 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
} }
} }
return strings.TrimRight(trimmed, "/"), nil 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 ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) { func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
// 最小格式校验:仅保证 URL 可解析且 scheme 合规,不做白名单/私网/SSRF 校验
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
return "", errors.New("url is required") return "", errors.New("url is required")
...@@ -59,29 +80,29 @@ func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) { ...@@ -59,29 +80,29 @@ func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
if err != nil || parsed.Scheme == "" || parsed.Host == "" { if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("invalid url: %s", trimmed) return "", fmt.Errorf("invalid url: %s", trimmed)
} }
if !strings.EqualFold(parsed.Scheme, "https") {
scheme := strings.ToLower(parsed.Scheme)
if scheme != "https" && (!allowInsecureHTTP || scheme != "http") {
return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme) return "", fmt.Errorf("invalid url scheme: %s", parsed.Scheme)
} }
host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) host := strings.TrimSpace(parsed.Hostname())
if host == "" { if host == "" {
return "", errors.New("invalid 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 port := parsed.Port(); port != "" {
if opts.RequireAllowlist && len(allowlist) == 0 { num, err := strconv.Atoi(port)
return "", errors.New("allowlist is not configured") if err != nil || num <= 0 || num > 65535 {
return "", fmt.Errorf("invalid port: %s", port)
} }
if len(allowlist) > 0 && !isAllowedHost(host, allowlist) {
return "", fmt.Errorf("host is not allowed: %s", host)
} }
parsed.Path = strings.TrimRight(parsed.Path, "/") return strings.TrimRight(trimmed, "/"), nil
parsed.RawPath = "" }
return strings.TrimRight(parsed.String(), "/"), nil
func ValidateHTTPSURL(raw string, opts ValidationOptions) (string, error) {
return ValidateHTTPURL(raw, false, opts)
} }
// ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全 // ValidateResolvedIP 验证 DNS 解析后的 IP 地址是否安全
......
...@@ -161,6 +161,19 @@ TOTP_ENCRYPTION_KEY= ...@@ -161,6 +161,19 @@ TOTP_ENCRYPTION_KEY=
# Leave unset to use default ./config.yaml # Leave unset to use default ./config.yaml
#CONFIG_FILE=./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) # Rate Limiting (Optional)
# 速率限制(可选) # 速率限制(可选)
......
...@@ -303,6 +303,10 @@ Requires your own OAuth client credentials. ...@@ -303,6 +303,10 @@ Requires your own OAuth client credentials.
```bash ```bash
GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret 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** **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 ...@@ -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 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: 3. Reload and restart:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
......
...@@ -707,10 +707,14 @@ turnstile: ...@@ -707,10 +707,14 @@ turnstile:
# 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同) # 默认:使用 Gemini CLI 的公开 OAuth 凭证(与 Google 官方 CLI 工具相同)
gemini: gemini:
oauth: oauth:
# Gemini CLI public OAuth credentials (works for both Code Assist and AI Studio) # OAuth 客户端配置说明:
# Gemini CLI 公开 OAuth 凭证(适用于 Code Assist 和 AI Studio) # 1) 留空 client_id/client_secret:使用 Gemini CLI 内置 OAuth Client(其 client_secret 需通过环境变量注入)
client_id: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" # - GEMINI_CLI_OAUTH_CLIENT_SECRET
client_secret: "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" # 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. # Optional scopes (space-separated). Leave empty to auto-select based on oauth_type.
# 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。 # 可选的权限范围(空格分隔)。留空则根据 oauth_type 自动选择。
scopes: "" scopes: ""
......
...@@ -125,6 +125,11 @@ services: ...@@ -125,6 +125,11 @@ services:
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - 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) # Security Configuration (URL Allowlist)
# ======================================================================= # =======================================================================
......
...@@ -104,6 +104,11 @@ services: ...@@ -104,6 +104,11 @@ services:
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - 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) # Security Configuration (URL Allowlist)
# ======================================================================= # =======================================================================
......
...@@ -123,6 +123,11 @@ services: ...@@ -123,6 +123,11 @@ services:
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - 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) # Security Configuration (URL Allowlist)
# ======================================================================= # =======================================================================
......
...@@ -88,6 +88,11 @@ services: ...@@ -88,6 +88,11 @@ services:
- GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-} - GEMINI_OAUTH_CLIENT_SECRET=${GEMINI_OAUTH_CLIENT_SECRET:-}
- GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-} - GEMINI_OAUTH_SCOPES=${GEMINI_OAUTH_SCOPES:-}
- GEMINI_QUOTA_POLICY=${GEMINI_QUOTA_POLICY:-} - 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: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment