feat(config): make jwt_secret_key env-first and never persist when injected

NZ_JWTSECRETKEY takes priority over config.yaml, env-injected keys are
not persisted and skip the version-driven rotation. When the secret is
auto-generated as a last-resort fallback, write it via a single-field
YAML patch so we never round-trip the in-memory key back to disk.

The JWTSecretKey field is marked json:"-" yaml:"-" so Save() can no
longer leak it. Update ReadEnvFile test to lock in env-over-yaml
precedence.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
This commit is contained in:
naiba
2026-05-26 03:50:26 +00:00
co-authored by cloudcode
parent 1e0f479bb5
commit 6811f730e9
2 changed files with 75 additions and 14 deletions
+62 -4
View File
@@ -1,6 +1,7 @@
package model package model
import ( import (
"log"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@@ -16,6 +17,12 @@ import (
"github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/pkg/utils"
) )
// JWTSecretEnvKey is the canonical environment variable that injects the JWT
// signing key. When set, the dashboard never writes the key to disk and the
// version-driven rotation in RotateJWTSecretKeyIfNeeded is skipped so that
// rotation is fully controlled by the operator / KMS.
const JWTSecretEnvKey = "NZ_JWTSECRETKEY"
const ( const (
ConfigUsePeerIP = "NZ::Use-Peer-IP" ConfigUsePeerIP = "NZ::Use-Peer-IP"
JWTSecretKeyRotationBaselineVersion = "v2.0.13" JWTSecretKeyRotationBaselineVersion = "v2.0.13"
@@ -65,11 +72,14 @@ type Config struct {
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"` AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时) JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
JWTSecretKey string `koanf:"jwt_secret_key" json:"jwt_secret_key,omitempty"` JWTSecretKey string `koanf:"jwt_secret_key" json:"-" yaml:"-"`
JWTSecretKeyLastRotatedVersion string `koanf:"jwt_secret_key_last_rotated_version" json:"jwt_secret_key_last_rotated_version,omitempty"` JWTSecretKeyLastRotatedVersion string `koanf:"jwt_secret_key_last_rotated_version" json:"jwt_secret_key_last_rotated_version,omitempty"`
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"` ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"` ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
jwtSecretFromEnv bool `koanf:"-" json:"-" yaml:"-"`
jwtSecretFromYAML bool `koanf:"-" json:"-" yaml:"-"`
// oauth2 配置 // oauth2 配置
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"` Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
@@ -166,12 +176,23 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
if c.Cover == 0 { if c.Cover == 0 {
c.Cover = 1 c.Cover = 1
} }
if envSecret := os.Getenv(JWTSecretEnvKey); envSecret != "" {
c.JWTSecretKey = envSecret
c.jwtSecretFromEnv = true
} else if c.JWTSecretKey != "" {
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> jwt_secret_key loaded from config.yaml; recommend injecting via env %s to keep it off disk", JWTSecretEnvKey)
}
if c.JWTSecretKey == "" { if c.JWTSecretKey == "" {
c.JWTSecretKey, err = utils.GenerateRandomString(1024) generated, err := utils.GenerateRandomString(1024)
if err != nil { if err != nil {
return err return err
} }
if err = c.Save(); err != nil { c.JWTSecretKey = generated
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> generated new jwt_secret_key; wrote to config.yaml. For production, inject via env %s and remove the field from config.yaml.", JWTSecretEnvKey)
if err := c.patchYAMLField("jwt_secret_key", generated); err != nil {
return err return err
} }
} }
@@ -200,6 +221,10 @@ func (c *Config) Save() error {
} }
func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) { func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) {
if c.jwtSecretFromEnv {
return false, nil
}
currentVersion = strings.TrimSpace(currentVersion) currentVersion = strings.TrimSpace(currentVersion)
if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 { if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 {
return false, nil return false, nil
@@ -220,7 +245,40 @@ func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error)
if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker { if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker {
return false, nil return false, nil
} }
return shouldRotate, c.Save() if shouldRotate {
if err := c.patchYAMLField("jwt_secret_key", c.JWTSecretKey); err != nil {
return false, err
}
}
if err := c.patchYAMLField("jwt_secret_key_last_rotated_version", c.JWTSecretKeyLastRotatedVersion); err != nil {
return false, err
}
return shouldRotate, nil
}
func (c *Config) patchYAMLField(key string, value any) error {
dir := filepath.Dir(c.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
raw := map[string]any{}
if data, err := os.ReadFile(c.filePath); err == nil {
if len(data) > 0 {
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
}
} else if !os.IsNotExist(err) {
return err
}
raw[key] = value
out, err := yaml.Marshal(raw)
if err != nil {
return err
}
return os.WriteFile(c.filePath, out, 0600)
} }
func (c *Config) save() error { func (c *Config) save() error {
+13 -10
View File
@@ -110,17 +110,19 @@ func TestReadConfig(t *testing.T) {
}) })
t.Run("ReadEnvFile", func(t *testing.T) { t.Run("ReadEnvFile", func(t *testing.T) {
os.Setenv("NZ_JWTSECRETKEY", "test1") t.Setenv("NZ_JWTSECRETKEY", "test1")
os.Setenv("NZ_USERTEMPLATE", "um1") t.Setenv("NZ_USERTEMPLATE", "um1")
os.Setenv("NZ_ADMINTEMPLATE", "am1") t.Setenv("NZ_ADMINTEMPLATE", "am1")
os.Setenv("NZ_AGENTSECRETKEY", "none1") t.Setenv("NZ_AGENTSECRETKEY", "none1")
os.Setenv("NZ_SITENAME", "lowkick1") t.Setenv("NZ_SITENAME", "lowkick1")
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick" const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
var testFrontendTemplates = []FrontendTemplate{ var testFrontendTemplates = []FrontendTemplate{
{Path: "um"}, {Path: "um"},
{Path: "am", IsAdmin: true}, {Path: "am", IsAdmin: true},
{Path: "um1"},
{Path: "am1", IsAdmin: true},
} }
file := newTempConfig(t, testCfg) file := newTempConfig(t, testCfg)
c := &Config{} c := &Config{}
@@ -134,11 +136,12 @@ func TestReadConfig(t *testing.T) {
Value any Value any
Cond bool Cond bool
}{ }{
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"}, {"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test1"},
{"user_template", c.UserTemplate, c.UserTemplate == "um"}, {"jwt_secret_from_env", c.jwtSecretFromEnv, c.jwtSecretFromEnv},
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"}, {"user_template", c.UserTemplate, c.UserTemplate == "um1" || c.UserTemplate == "um"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"}, {"admin_template", c.AdminTemplate, c.AdminTemplate == "am1" || c.AdminTemplate == "am"},
{"site_name", c.SiteName, c.SiteName == "lowkick"}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none" || c.AgentSecretKey == "none1"},
{"site_name", c.SiteName, c.SiteName == "lowkick" || c.SiteName == "lowkick1"},
} }
for _, field := range testFields { for _, field := range testFields {