开源前加固认证与部署契约:加密随机 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user