fix(security): 修复密码重置链接 Host Header 注入漏洞 (P0-07)

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 <noreply@anthropic.com>
This commit is contained in:
yangjianbo
2026-02-07 17:15:26 +08:00
parent 8226a4ce4d
commit e75d3e3584
4 changed files with 38 additions and 10 deletions

View File

@@ -148,6 +148,7 @@ type ServerConfig struct {
Host string `mapstructure:"host"` Host string `mapstructure:"host"`
Port int `mapstructure:"port"` Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug/release Mode string `mapstructure:"mode"` // debug/release
FrontendURL string `mapstructure:"frontend_url"` // 前端基础 URL用于生成邮件中的外部链接
ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒) ReadHeaderTimeout int `mapstructure:"read_header_timeout"` // 读取请求头超时(秒)
IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒) IdleTimeout int `mapstructure:"idle_timeout"` // 空闲连接超时(秒)
TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表CIDR/IP TrustedProxies []string `mapstructure:"trusted_proxies"` // 可信代理列表CIDR/IP
@@ -641,6 +642,7 @@ func Load() (*Config, error) {
if cfg.Server.Mode == "" { if cfg.Server.Mode == "" {
cfg.Server.Mode = "debug" cfg.Server.Mode = "debug"
} }
cfg.Server.FrontendURL = strings.TrimSpace(cfg.Server.FrontendURL)
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret) cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID) cfg.LinuxDo.ClientID = strings.TrimSpace(cfg.LinuxDo.ClientID)
cfg.LinuxDo.ClientSecret = strings.TrimSpace(cfg.LinuxDo.ClientSecret) 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.host", "0.0.0.0")
viper.SetDefault("server.port", 8080) viper.SetDefault("server.port", 8080)
viper.SetDefault("server.mode", "debug") viper.SetDefault("server.mode", "debug")
viper.SetDefault("server.frontend_url", "")
viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头 viper.SetDefault("server.read_header_timeout", 30) // 30秒读取请求头
viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时 viper.SetDefault("server.idle_timeout", 120) // 120秒空闲超时
viper.SetDefault("server.trusted_proxies", []string{}) viper.SetDefault("server.trusted_proxies", []string{})
@@ -950,6 +953,12 @@ func setDefaults() {
} }
func (c *Config) Validate() error { 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 { if c.JWT.ExpireHour <= 0 {
return fmt.Errorf("jwt.expire_hour must be positive") return fmt.Errorf("jwt.expire_hour must be positive")
} }

View File

@@ -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) { func TestValidateFrontendRedirectURL(t *testing.T) {
if err := ValidateFrontendRedirectURL("/auth/callback"); err != nil { if err := ValidateFrontendRedirectURL("/auth/callback"); err != nil {
t.Fatalf("ValidateFrontendRedirectURL relative error: %v", err) t.Fatalf("ValidateFrontendRedirectURL relative error: %v", err)

View File

@@ -2,6 +2,7 @@ package handler
import ( import (
"log/slog" "log/slog"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/handler/dto"
@@ -448,17 +449,12 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
return return
} }
// Build frontend base URL from request frontendBaseURL := strings.TrimSpace(h.cfg.Server.FrontendURL)
scheme := "https" if frontendBaseURL == "" {
if c.Request.TLS == nil { slog.Error("server.frontend_url not configured; cannot build password reset link")
// Check X-Forwarded-Proto header (common in reverse proxy setups) response.InternalError(c, "Password reset is not configured")
if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" { return
scheme = proto
} else {
scheme = "http"
}
} }
frontendBaseURL := scheme + "://" + c.Request.Host
// Request password reset (async) // Request password reset (async)
// Note: This returns success even if email doesn't exist (to prevent enumeration) // Note: This returns success even if email doesn't exist (to prevent enumeration)

View File

@@ -20,6 +20,10 @@ server:
# Mode: "debug" for development, "release" for production # Mode: "debug" for development, "release" for production
# 运行模式:"debug" 用于开发,"release" 用于生产环境 # 运行模式:"debug" 用于开发,"release" 用于生产环境
mode: "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. # Trusted proxies for X-Forwarded-For parsing (CIDR/IP). Empty disables trusted proxies.
# 信任的代理地址CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。 # 信任的代理地址CIDR/IP 格式),用于解析 X-Forwarded-For 头。留空则禁用代理信任。
trusted_proxies: [] trusted_proxies: []