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 全绿
174 lines
5.3 KiB
Go
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
|
|
}
|