Files
go_blog/config/config.go
T
kevin 38cd09f723 fix: 安全加固,修复 P0/P1 安全漏洞
P0(高危):
- 新增全局 CSRF 中间件(同步器令牌),覆盖全部 30 个表单与 AJAX 请求
- 修复附件上传/列表/删除越权(IDOR),增加 admin/上传者/文章作者所有权校验
- 登录/注册成功后会话轮换,修复会话固定
- 会话密钥改用 crypto/rand 生成,配置缺失 secret 时拒绝启动

P1(中危):
- session 与 comment_uid cookie 增加 Secure/SameSite 标志
- 新增安全响应头:CSP、X-Content-Type-Options、X-Frame-Options、HSTS 等
- 新增 web.trusted_proxies 配置,修复 X-Forwarded-For 伪造
- 修复浏览量记录 goroutine 访问已回收 gin.Context 的数据竞争

补充 17 个安全回归测试(middleware/handlers),go test -race 全绿
2026-08-19 12:33:09 +08:00

174 lines
5.3 KiB
Go

package config
import (
"crypto/rand"
"encoding/hex"
"log"
"os"
"path/filepath"
"runtime"
"gopkg.in/yaml.v3"
)
// Config holds all application configuration.
type Config struct {
Database DatabaseConfig `yaml:"database"`
Web WebConfig `yaml:"web"`
Path string `yaml:"path"`
Secret string `yaml:"secret"`
}
// DatabaseConfig holds database-specific configuration.
type DatabaseConfig struct {
Type string `yaml:"type"` // "sqlite" (default) or "mysql"
DSN string `yaml:"dsn"` // MySQL connection string (required when type is "mysql")
}
// WebConfig holds web-server listening configuration.
type WebConfig struct {
Port string `yaml:"port"` // TCP port, "" or "0" to disable
Socket string `yaml:"socket"` // Unix socket path, "" to disable
// TrustedProxies lists proxy IPs/CIDRs whose X-Forwarded-For /
// X-Forwarded-Proto headers are trusted (e.g. the Caddy/nginx box in
// front of the app). Defaults to loopback. If the app is exposed
// directly to clients, leave the default so client-supplied
// X-Forwarded-For cannot spoof the logged IP.
TrustedProxies []string `yaml:"trusted_proxies"`
}
// defaultTrustedProxies is used when the config omits trusted_proxies.
var defaultTrustedProxies = []string{"127.0.0.1", "::1"}
const defaultPort = "8080"
// mysqlExampleDSN is written into new config files as a reference.
const mysqlExampleDSN = "user:password@tcp(127.0.0.1:3306)/blog_go?charset=utf8mb4&parseTime=True&loc=Local"
// getConfigPath returns the OS-aware config directory and config file path.
func getConfigPath() (dir, file string) {
switch runtime.GOOS {
case "windows":
dir = filepath.Join(".", "win", "etc", "blog_go")
case "darwin":
dir = filepath.Join(".", "mac", "etc", "blog_go")
default:
dir = filepath.Join("/", "etc", "blog_go")
}
file = filepath.Join(dir, "config.yaml")
return
}
// getDefaultStoragePath returns the OS-aware default storage path.
func getDefaultStoragePath() string {
switch runtime.GOOS {
case "windows":
return filepath.Join(".", "win", "srv", "blog_go")
case "darwin":
return filepath.Join(".", "mac", "srv", "blog_go")
default:
return filepath.Join("/", "srv", "blog_go")
}
}
// generateSecret returns a cryptographically random hex string for the
// session secret. A failure of crypto/rand is unrecoverable, so the program
// terminates instead of falling back to a predictable value.
func generateSecret() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
log.Fatalf("Failed to generate session secret: %v", err)
}
return hex.EncodeToString(b)
}
// getDefaultSocketPath returns the OS-aware default unix socket path.
func getDefaultSocketPath() string {
if runtime.GOOS == "linux" {
return "/run/blog_go/web.sock"
}
return ""
}
// LoadConfig reads the config file or creates one with defaults.
// If customPath is non-empty, it overrides the OS-aware config file path.
func LoadConfig(customPath string) *Config {
configDir, configFile := getConfigPath()
if customPath != "" {
configFile = customPath
configDir = filepath.Dir(configFile)
}
defaultPath := getDefaultStoragePath()
// Check if config file exists; create with defaults if not.
if _, err := os.Stat(configFile); os.IsNotExist(err) {
log.Printf("Config file not found at %s, creating with defaults...", configFile)
if err := os.MkdirAll(configDir, 0755); err != nil {
log.Fatalf("Failed to create config directory %s: %v", configDir, err)
}
cfg := &Config{
Database: DatabaseConfig{
Type: "sqlite",
DSN: mysqlExampleDSN,
},
Web: WebConfig{
Port: defaultPort,
Socket: getDefaultSocketPath(),
},
Path: defaultPath,
Secret: generateSecret(),
}
data, err := yaml.Marshal(cfg)
if err != nil {
log.Fatalf("Failed to marshal default config: %v", err)
}
if err := os.WriteFile(configFile, data, 0644); err != nil {
log.Fatalf("Failed to write config file %s: %v", configFile, err)
}
log.Printf("Default config created at %s", configFile)
return cfg
}
// Read existing config file.
data, err := os.ReadFile(configFile)
if err != nil {
log.Fatalf("Failed to read config file %s: %v", configFile, err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
log.Printf("Warning: malformed config file %s: %v, using defaults", configFile, err)
}
return applyDefaults(cfg, defaultPath, configFile)
}
// applyDefaults fills zero-value fields with sensible defaults.
func applyDefaults(cfg *Config, defaultPath, configFile string) *Config {
// If the entire web block is empty (old config without "web" key),
// fill default port so the app still starts on 8080.
if cfg.Web.Port == "" && cfg.Web.Socket == "" {
cfg.Web.Port = defaultPort
}
if len(cfg.Web.TrustedProxies) == 0 {
cfg.Web.TrustedProxies = defaultTrustedProxies
}
if cfg.Database.Type == "" {
cfg.Database.Type = "sqlite"
}
if cfg.Path == "" {
cfg.Path = defaultPath
}
if cfg.Secret == "" {
// The config file exists but has no secret. Refuse to start: a
// silently generated fallback would either be predictable (old
// hostname+pid scheme) or invalidate all sessions on every restart.
log.Fatalf("Config file %s is missing a session secret. "+
"Add a random value, e.g. `secret: %s`, and restart.",
configFile, generateSecret())
}
return cfg
}