开源前加固认证与部署契约:加密随机 JWT、改密事务吊销、登录 CSRF,并禁止生产回落 localhost。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 02:07:34 +08:00
parent 6f054a903c
commit 3b550f2124
24 changed files with 372 additions and 355 deletions

View File

@@ -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
}

View File

@@ -114,14 +114,61 @@ func TestAllowOriginDevAndProd(t *testing.T) {
}
}
const testJWTSecret = "unit-test-jwt-secret-32chars-min!!" // 满足 ≥32,非生产用
func TestIsWeakJWTSecret(t *testing.T) {
if !isWeakJWTSecret("") || !isWeakJWTSecret("short") {
t.Fatal("空/过短应判弱")
}
if !isWeakJWTSecret("abcdefghijklmnopqrstuvwxyzABCDEF") {
t.Fatal("旧确定性密钥应判弱")
}
if !isWeakJWTSecret("change-me") {
t.Fatal("占位值应判弱")
}
if isWeakJWTSecret(testJWTSecret) {
t.Fatal("测试强密钥不应判弱")
}
}
func TestGenerateSecretNotDeterministic(t *testing.T) {
a, err := generateSecret(32)
if err != nil {
t.Fatal(err)
}
b, err := generateSecret(32)
if err != nil {
t.Fatal(err)
}
if a == b {
t.Fatal("连续两次生成不应相同")
}
if isWeakJWTSecret(a) {
t.Fatalf("生成结果被误判为弱: %q", a)
}
}
func TestParseRejectsWeakExplicitSecret(t *testing.T) {
work := t.TempDir()
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = change-me\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("JIANG13_WORK_PATH", work)
t.Setenv("JWT_SECRET", "")
if _, err := Parse(); err == nil {
t.Fatal("显式弱密钥应拒绝启动")
}
}
func TestParseSettingsMasterKeyFromIni(t *testing.T) {
work := t.TempDir()
key := "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = test-secret-not-for-prod\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil {
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte("[security]\nJWT_SECRET = "+testJWTSecret+"\nSETTINGS_MASTER_KEY = "+key+"\n"), 0600); err != nil {
t.Fatal(err)
}
t.Setenv("JIANG13_WORK_PATH", work)
t.Setenv("SETTINGS_MASTER_KEY", "")
t.Setenv("JWT_SECRET", "")
cfg, err := Parse()
if err != nil {
t.Fatal(err)
@@ -149,7 +196,7 @@ func TestParseSiteURLAndDataDir(t *testing.T) {
t.Setenv("SITE_URL", "https://bbs.example.com/")
t.Setenv("CORS_ORIGINS", " https://a.example.com ,https://b.example.com/ ")
t.Setenv("DATA_DIR", data)
t.Setenv("JWT_SECRET", "test-secret-not-for-prod")
t.Setenv("JWT_SECRET", testJWTSecret)
cfg, err := Parse()
if err != nil {
@@ -210,7 +257,7 @@ func TestEnsureAppIniBackfillsMissingKeys(t *testing.T) {
func TestParseSiteURLFromIni(t *testing.T) {
work := t.TempDir()
body := "[security]\nJWT_SECRET = test-secret-not-for-prod\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
body := "[security]\nJWT_SECRET = "+testJWTSecret+"\n\n[app]\nDEV_MODE = true\nSITE_URL = https://forum.example.com/\nCORS_ORIGINS = https://a.example.com, https://b.example.com/\n"
if err := os.WriteFile(filepath.Join(work, "app.ini"), []byte(body), 0600); err != nil {
t.Fatal(err)
}