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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user