fix(apiserver): never sign or accept JWTs with the shipped default secret (bug-1945)

Cause: config/app.yaml shipped secretKey "xce-api-key" and
resolveJWTSecret used it whenever XCE_JWT_SECRET was unset, so anyone
could forge tokens for any user on such a deployment.

Fix: resolveJWTSecret uses XCE_JWT_SECRET, else the config value, but
treats an empty value or the old public default as unset; then it
generates a random 256-bit per-process secret and InitConf logs a warning
(secret never logged): tokens do not survive a restart and apiservers
sharing one endpoint must set the same XCE_JWT_SECRET. The shipped
app.yaml no longer carries a secret. The controllers test secret is no
longer the old default.

Tests: TestResolveJWTSecret_NeverUsesKnownDefaultOrEmpty,
TestShippedAppYAML_NoJWTSecret, TestInitConf_NoJWTSecretGeneratesAndWarns,
TestInitConf_JWTSecretFromEnvNoWarning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-10 08:37:01 +00:00
parent c4c63f11e7
commit 19a1a6f023
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
4 changed files with 131 additions and 8 deletions

View file

@ -1,6 +1,8 @@
package utils
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log"
@ -164,10 +166,18 @@ func InitConf() error {
AppVersion: viper.GetString("app.version"),
}
// parse authentication section
configuredSecret := viper.GetString("authentication.token.secretKey")
jwtSecret := resolveJWTSecret(configuredSecret)
if jwtSecret != configuredSecret && jwtSecret != os.Getenv("XCE_JWT_SECRET") {
Warn("No JWT secret configured (XCE_JWT_SECRET or authentication.token.secretKey), " +
"or it is the publicly known default: using a random per-process secret. " +
"Tokens do not survive an apiserver restart, and apiservers sharing one endpoint " +
"must all set the same XCE_JWT_SECRET")
}
AuthenticationYamlConfig = &Authentication{
Token: TokenConfig{
Type: viper.GetString("authentication.token.type"),
SecretKey: resolveJWTSecret(viper.GetString("authentication.token.secretKey")),
SecretKey: jwtSecret,
TokenExpireTime: int(viper.GetFloat64("authentication.token.token_expire_time")),
},
}
@ -340,11 +350,31 @@ func resolveTrustedProxies(entries []string) ([]string, error) {
return proxies, nil
}
// resolveJWTSecret returns the JWT secret from XCE_JWT_SECRET env var if set and non-empty,
// otherwise falls back to the value from config file.
// knownDefaultJWTSecret is the secret older app.yaml files shipped with; it is
// public, so it is treated as "no secret configured" (bug-1945).
const knownDefaultJWTSecret = "xce-api-key"
// usableJWTSecret reports whether s may sign tokens: non-empty and not the
// publicly known default.
func usableJWTSecret(s string) bool {
return s != "" && s != knownDefaultJWTSecret
}
// resolveJWTSecret returns the JWT secret: XCE_JWT_SECRET if usable, else
// the config file value if usable, else a random per-process secret (the
// caller detects that case because the result differs from both inputs).
func resolveJWTSecret(configValue string) string {
if env := os.Getenv("XCE_JWT_SECRET"); env != "" {
if env := os.Getenv("XCE_JWT_SECRET"); usableJWTSecret(env) {
return env
}
return configValue
if usableJWTSecret(configValue) {
return configValue
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
// crypto/rand never fails on supported platforms; refuse to fall
// back to a predictable secret if it ever does.
panic(fmt.Sprintf("cannot generate a JWT secret: %v", err))
}
return hex.EncodeToString(buf)
}