开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -35,7 +38,7 @@ type appIniKey struct {
|
||||
var appIniSchema = []appIniKey{
|
||||
{section: "server", key: "HTTP_PORT", value: "3001"},
|
||||
{section: "database", key: "DSN", value: "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", comment: "PostgreSQL 连接串(部署时请修改账号密码)"},
|
||||
{section: "security", key: "JWT_SECRET", value: "", comment: "留空则自动生成并持久化到 data/.jwt_secret;生产环境务必显式指定强随机值"},
|
||||
{section: "security", key: "JWT_SECRET", value: "", comment: "留空则用 crypto/rand 生成并持久化到 data/.jwt_secret;生产务必显式指定 ≥32 字符强随机值,禁止 change-me 等占位"},
|
||||
{section: "security", key: "SETTINGS_MASTER_KEY", value: "", comment: "32 字节随机值的标准 Base64(openssl rand -base64 32)。留空则不能保存邮件/S3 凭据。\n环境变量 SETTINGS_MASTER_KEY 优先于此处。丢失后已加密凭据无法解密,不要提交真实值。"},
|
||||
{section: "paths", key: "DATA", value: "data"},
|
||||
{section: "app", key: "DEV_MODE", value: "true"},
|
||||
@@ -112,14 +115,28 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||
}
|
||||
|
||||
// JWT 密钥:留空则自动生成并持久化
|
||||
if strings.TrimSpace(cfg.JWTSecret) == "" {
|
||||
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
|
||||
// JWT 密钥:显式配置优先;留空则从 data/.jwt_secret 读取或用加密随机生成
|
||||
secretFile := filepath.Join(cfg.DataDir, ".jwt_secret")
|
||||
explicitSecret := strings.TrimSpace(cfg.JWTSecret) != ""
|
||||
if !explicitSecret {
|
||||
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||
cfg.JWTSecret = string(data)
|
||||
} else {
|
||||
cfg.JWTSecret = generateSecret(32)
|
||||
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
|
||||
cfg.JWTSecret = strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
if isWeakJWTSecret(cfg.JWTSecret) {
|
||||
if explicitSecret {
|
||||
return nil, fmt.Errorf("JWT_SECRET 过弱、过短或为已知占位/确定性旧值,请设置至少 32 字符的强随机密钥(例如:openssl rand -base64 32)")
|
||||
}
|
||||
if cfg.JWTSecret != "" {
|
||||
log.Println("[config] 检测到弱/确定性 .jwt_secret,正在轮换为加密随机密钥")
|
||||
}
|
||||
secret, err := generateSecret(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成 JWT 密钥失败: %w", err)
|
||||
}
|
||||
cfg.JWTSecret = secret
|
||||
if err := os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600); err != nil {
|
||||
return nil, fmt.Errorf("写入 .jwt_secret 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,11 +323,36 @@ func rejectStrayDataDir(dataDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSecret(n int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = chars[i%len(chars)]
|
||||
// generateSecret 用 crypto/rand 生成 URL-safe 密钥(约 n 字节熵)。
|
||||
func generateSecret(n int) (string, error) {
|
||||
if n < 32 {
|
||||
n = 32
|
||||
}
|
||||
return string(b)
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// isWeakJWTSecret 拒绝过短、占位示例、以及历史上确定性 generateSecret 产物。
|
||||
func isWeakJWTSecret(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 32 {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(s) {
|
||||
case "change-me",
|
||||
"your-jwt-secret-change-me",
|
||||
"change-me-to-a-long-random-string",
|
||||
"secret",
|
||||
"jwt-secret":
|
||||
return true
|
||||
}
|
||||
// 旧版确定性循环字符表:abcdefghijklmnopqrstuvwxyzABCDEF...
|
||||
const legacyCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
if len(s) <= len(legacyCharset) && s == legacyCharset[:len(s)] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user