From e75d3e35840a3055f383672943b3f1cc24014856 Mon Sep 17 00:00:00 2001 From: yangjianbo Date: Sat, 7 Feb 2026 17:15:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20=E4=BF=AE=E5=A4=8D=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E9=87=8D=E7=BD=AE=E9=93=BE=E6=8E=A5=20Host=20Header?= =?UTF-8?q?=20=E6=B3=A8=E5=85=A5=E6=BC=8F=E6=B4=9E=20(P0-07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForgotPassword 原来从 c.Request.Host 构建重置链接基础 URL,攻击者 可伪造 Host 头将重置链接指向恶意域名窃取 token。 修复方案: - ServerConfig 新增 frontend_url 配置项 - auth_handler 改为从配置读取前端 URL,未配置时拒绝请求 - Validate() 校验 frontend_url 必须为绝对 HTTP(S) URL - 新增 TestValidateServerFrontendURL 单元测试 - config.example.yaml 添加配置说明 Co-Authored-By: Claude Opus 4.6 --- backend/internal/config/config.go | 9 +++++++++ backend/internal/config/config_test.go | 19 +++++++++++++++++++ backend/internal/handler/auth_handler.go | 16 ++++++---------- deploy/config.example.yaml | 4 ++++ 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 64132a2f..aeaadb2d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -148,6 +148,7 @@ type ServerConfig struct { Host string `mapstructure:"host"` Port int `mapstructure:"port"` Mode string `mapstructure:"mode"` // debug/release + FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL,用于生成邮件中的外部链接 ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表(CIDR/IP) @@ -641,6 +642,7 @@ func Load() (*Config, error) { if cfg.Server.Mode == "" { cfg.Server.Mode = "debug" } + cfg.Server.FrontendURL = strings.TrimSpace(cfg.Server.FrontendURL) cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret) cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID) cfg.LinuxDo.ClientSecret = strings.TrimSpace(cfg.LinuxDo.ClientSecret) @@ -714,6 +716,7 @@ func setDefaults() { viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.port", 8080) viper.SetDefault("server.mode", "debug") + viper.SetDefault("server.frontend_url", "") viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头 viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时 viper.SetDefault("server.trusted_proxies", []string{}) @@ -950,6 +953,12 @@ func setDefaults() { } func (c *Config) Validate() error { + if strings.TrimSpace(c.Server.FrontendURL) != "" { + if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil { + return fmt.Errorf("server.frontend_url invalid: %w", err) + } + warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL) + } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f734619f..5fd2672a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -424,6 +424,25 @@ func TestValidateAbsoluteHTTPURL(t *testing.T) { } } +func TestValidateServerFrontendURL(t *testing.T) { + viper.Reset() + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + + cfg.Server.FrontendURL = "https://example.com" + if err := cfg.Validate(); err != nil { + t.Fatalf("Validate() frontend_url valid error: %v", err) + } + + cfg.Server.FrontendURL = "/relative" + if err := cfg.Validate(); err == nil { + t.Fatalf("Validate() should reject relative server.frontend_url") + } +} + func TestValidateFrontendRedirectURL(t *testing.T) { if err := ValidateFrontendRedirectURL("/auth/callback"); err != nil { t.Fatalf("ValidateFrontendRedirectURL relative error: %v", err) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 34ed63bc..204af666 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2,6 +2,7 @@ package handler import ( "log/slog" + "strings" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" @@ -448,17 +449,12 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { return } - // Build frontend base URL from request - scheme := "https" - if c.Request.TLS == nil { - // Check X-Forwarded-Proto header (common in reverse proxy setups) - if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { - scheme = proto - } else { - scheme = "http" - } + frontendBaseURL := strings.TrimSpace(h.cfg.Server.FrontendURL) + if frontendBaseURL == "" { + slog.Error("server.frontend_url not configured; cannot build password reset link") + response.InternalError(c, "Password reset is not configured") + return } - frontendBaseURL := scheme + "://" + c.Request.Host // Request password reset (async) // Note: This returns success even if email doesn't exist (to prevent enumeration) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 27fe6ad8..dbf0703d 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -20,6 +20,10 @@ server: # Mode: "debug" for development, "release" for production # 运行模式:"debug" 用于开发,"release" 用于生产环境 mode: "release" + # Frontend base URL used to generate external links in emails (e.g. password reset) + # 用于生成邮件中的外部链接(例如:重置密码链接)的前端基础地址 + # Example: "https://example.com" + frontend_url: "" # Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies. # 信任的代理地址(CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。 trusted_proxies: []