fix(backend): secure production startup and migrations

This commit is contained in:
2026-07-22 13:11:30 +08:00
parent 0daea4def9
commit 7980943660
13 changed files with 220 additions and 43 deletions

View File

@@ -2,6 +2,7 @@ package config
import (
"fmt"
"math"
"os"
"path/filepath"
"strings"
@@ -43,6 +44,17 @@ func LoadFromDir(configDir string) (Config, error) {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, err
}
selectedEnvironment, err := canonicalEnvironment(mode)
if err != nil {
return Config{}, err
}
configuredEnvironment, err := canonicalEnvironment(cfg.Env)
if err != nil {
return Config{}, err
}
if selectedEnvironment != configuredEnvironment {
return Config{}, fmt.Errorf("SENLIN_APP_MODE %s does not match config env %s", mode, cfg.Env)
}
if cfg.MaxUploadBytes <= 0 {
cfg.MaxUploadBytes = 32 << 20
}
@@ -59,28 +71,57 @@ func LoadFromDir(configDir string) (Config, error) {
if !hasAllowedOrigin {
return Config{}, fmt.Errorf("allowed_origins must include at least one origin")
}
if err := validateSecret(cfg.Env, "auth_secret", cfg.AuthSecret); err != nil {
production := selectedEnvironment == "production"
if err := validateSecret(production, "auth_secret", cfg.AuthSecret); err != nil {
return Config{}, err
}
if err := validateSecret(cfg.Env, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil {
if err := validateSecret(production, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil {
return Config{}, err
}
return cfg, nil
}
func validateSecret(environment, field, value string) error {
func canonicalEnvironment(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "dev", "development":
return "development", nil
case "prod", "production":
return "production", nil
case "test":
return "test", nil
default:
return "", fmt.Errorf("unsupported environment %q", value)
}
}
func validateSecret(production bool, field, value string) error {
secret := strings.TrimSpace(value)
if secret == "" {
return fmt.Errorf("%s must not be empty", field)
}
if strings.EqualFold(strings.TrimSpace(environment), "production") || strings.EqualFold(strings.TrimSpace(environment), "prod") {
if len(secret) < 32 || isCommonSecret(secret) {
return fmt.Errorf("%s must be at least 32 characters and must not use a development sentinel in production", field)
if production {
if len([]byte(secret)) < 32 || estimatedEntropyBits(secret) < 128 || isCommonSecret(secret) {
return fmt.Errorf("%s must contain at least 32 bytes and an estimated 128 bits of entropy, without development sentinels, in production", field)
}
}
return nil
}
func estimatedEntropyBits(value string) float64 {
data := []byte(value)
counts := make(map[byte]int, len(data))
for _, item := range data {
counts[item]++
}
length := float64(len(data))
entropyPerByte := 0.0
for _, count := range counts {
probability := float64(count) / length
entropyPerByte -= probability * math.Log2(probability)
}
return entropyPerByte * length
}
func isCommonSecret(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} {

View File

@@ -74,6 +74,28 @@ func TestLoadFromDirUsesSENLINAppMode(t *testing.T) {
require.Equal(t, []string{"https://workbench.example.com"}, cfg.AllowedOrigins)
}
func TestLoadFromDirRejectsProductionModeWithDevelopmentEnvironment(t *testing.T) {
configDir := t.TempDir()
writeConfig(t, configDir, "agent.prod.yaml", "development", "80", "postgres://prod", "/data/files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe")
t.Setenv("SENLIN_APP_MODE", "prod")
_, err := LoadFromDir(configDir)
require.ErrorContains(t, err, "SENLIN_APP_MODE prod")
require.ErrorContains(t, err, "env development")
}
func TestLoadFromDirRejectsModeEnvironmentMismatchOutsideProduction(t *testing.T) {
configDir := t.TempDir()
writeConfig(t, configDir, "agent.dev.yaml", "production", "9150", "postgres://dev", "./files", "production-auth-signing-key-2026-safe", "", "production-ai-encryption-key-2026-safe")
t.Setenv("SENLIN_APP_MODE", "dev")
_, err := LoadFromDir(configDir)
require.ErrorContains(t, err, "SENLIN_APP_MODE dev")
require.ErrorContains(t, err, "env production")
}
func TestLoadFromDirRejectsMissingStorageDir(t *testing.T) {
configDir := t.TempDir()
writeConfig(t, configDir, "agent.dev.yaml", "development", "9150", "postgres://agent", "", "dev-auth", "", "dev-ai")
@@ -111,9 +133,12 @@ func TestLoadFromDirRejectsUnsafeProductionSecrets(t *testing.T) {
{name: "short auth", authSecret: "short", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
{name: "development auth sentinel", authSecret: "development-auth-secret-change-me", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
{name: "dev auth sentinel", authSecret: "dev-secret-dev-secret-dev-secret-000", encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
{name: "repeated auth character", authSecret: strings.Repeat("x", 32), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
{name: "repeated auth pattern", authSecret: strings.Repeat("abcd", 8), encryptionSecret: "production-ai-encryption-key-2026-safe", expectedFieldName: "auth_secret"},
{name: "empty encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "", expectedFieldName: "ai_key_encryption_secret"},
{name: "short encryption", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "short", expectedFieldName: "ai_key_encryption_secret"},
{name: "common encryption sentinel", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: "change-me-change-me-change-me-change-me", expectedFieldName: "ai_key_encryption_secret"},
{name: "repeated encryption character", authSecret: "production-auth-signing-key-2026-safe", encryptionSecret: strings.Repeat("9", 64), expectedFieldName: "ai_key_encryption_secret"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {