BootstrapJWTSecret can return an empty string with a nil error, so CubeOps starts with a
zero-length HMAC key and any client that guesses this can forge a valid admin access token.
Environment
- CubeSandbox version / commit:
5960c56 (master)
- Host OS and kernel version: any
- KVM info (
modinfo kvm): n/a — control-plane only
- Deployment mode: single-node / cluster (both; the default one-click path is affected)
- Relevant component: CubeOps
Steps to Reproduce
The reachable trigger is a jwt_secret row that exists but holds an empty value, which the
one-click deployment can produce because it deliberately leaves JWT_SECRET unset
(deploy/one-click/env.example:257-258, deploy/one-click/scripts/systemd/cubeops-start.sh:27).
-
Deploy CubeOps without setting JWT_SECRET, so the secret is auto-generated into
t_system_setting.
-
Blank the stored value, simulating a truncated restore or a partially-applied migration:
UPDATE t_system_setting SET setting_value = '' WHERE setting_key = 'jwt_secret';
-
Restart CubeOps. It starts normally and logs
JWT secret loaded from database (t_system_setting).
-
Mint a token off-box with an empty HMAC key:
claims := AccessClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Subject: "admin",
Audience: jwt.ClaimStrings{"cubeops:access"},
},
Username: "admin", Role: "admin", Typ: "access",
}
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(""))
-
curl -H "Authorization: Bearer $tok" http://<cubeops>/api/v1/auth/session
The same end state is also reachable from a transient DB read error, because
GetSystemSetting discards the error whenever the scanned value is empty (see #5 — same root
cause, tracked separately).
Expected Behavior
CubeOps refuses to start when it cannot resolve a non-empty JWT signing secret, and the JWT
manager refuses to sign or verify with a zero-length key.
Actual Behavior
Startup succeeds and the forged token is accepted. Reproduced against the shipped claim shapes:
=== RUN TestEmptySecretSignsAndVerifies
jwt_test.go:39: server signed a token with an EMPTY secret: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
jwt_test.go:58: FORGED admin token ACCEPTED: username="admin" role="admin" typ="access"
--- PASS
golang-jwt/v5's SigningMethodHMAC.Sign accepts a []byte{} key without complaint, so an
empty secret is valid HMAC — every /api/v1 route behind auth.Middleware is reachable.
Additional Context
Call chain:
CubeOps/internal/store/setting.go:19-28 — GetSystemSetting returns ("", nil) for a row
that is missing, a value that is empty, and any DB error.
CubeOps/internal/store/setting.go:33-41 — GetOrCreateSystemSetting reads back through it.
CubeOps/internal/store/db.go:139-148 — BootstrapJWTSecret returns winner unchecked.
CubeOps/cmd/cubeops/main.go:53-58 — only err is checked.
CubeOps/internal/auth/jwt.go:54-60 — NewJWTManager stores []byte("") unvalidated.
bootstrapMasterKey (db.go:81-121) shares the first two links but fails safe, because
crypto.InstallMasterKey("") rejects a zero-length key (crypto/aes_gcm.go:60-62). The JWT path
has no equivalent guard.
Related: #5 (the store layer swallowing DB errors) is the upstream cause and is worth fixing on
its own; this issue is the missing guard that turns it into an auth bypass.
BootstrapJWTSecretcan return an empty string with anilerror, so CubeOps starts with azero-length HMAC key and any client that guesses this can forge a valid
adminaccess token.Environment
5960c56(master)modinfo kvm): n/a — control-plane onlySteps to Reproduce
The reachable trigger is a
jwt_secretrow that exists but holds an empty value, which theone-click deployment can produce because it deliberately leaves
JWT_SECRETunset(
deploy/one-click/env.example:257-258,deploy/one-click/scripts/systemd/cubeops-start.sh:27).Deploy CubeOps without setting
JWT_SECRET, so the secret is auto-generated intot_system_setting.Blank the stored value, simulating a truncated restore or a partially-applied migration:
UPDATE t_system_setting SET setting_value = '' WHERE setting_key = 'jwt_secret';Restart CubeOps. It starts normally and logs
JWT secret loaded from database (t_system_setting).Mint a token off-box with an empty HMAC key:
curl -H "Authorization: Bearer $tok" http://<cubeops>/api/v1/auth/sessionThe same end state is also reachable from a transient DB read error, because
GetSystemSettingdiscards the error whenever the scanned value is empty (see #5 — same rootcause, tracked separately).
Expected Behavior
CubeOps refuses to start when it cannot resolve a non-empty JWT signing secret, and the JWT
manager refuses to sign or verify with a zero-length key.
Actual Behavior
Startup succeeds and the forged token is accepted. Reproduced against the shipped claim shapes:
golang-jwt/v5'sSigningMethodHMAC.Signaccepts a[]byte{}key without complaint, so anempty secret is valid HMAC — every
/api/v1route behindauth.Middlewareis reachable.Additional Context
Call chain:
CubeOps/internal/store/setting.go:19-28—GetSystemSettingreturns("", nil)for a rowthat is missing, a value that is empty, and any DB error.
CubeOps/internal/store/setting.go:33-41—GetOrCreateSystemSettingreads back through it.CubeOps/internal/store/db.go:139-148—BootstrapJWTSecretreturnswinnerunchecked.CubeOps/cmd/cubeops/main.go:53-58— onlyerris checked.CubeOps/internal/auth/jwt.go:54-60—NewJWTManagerstores[]byte("")unvalidated.bootstrapMasterKey(db.go:81-121) shares the first two links but fails safe, becausecrypto.InstallMasterKey("")rejects a zero-length key (crypto/aes_gcm.go:60-62). The JWT pathhas no equivalent guard.
Related: #5 (the store layer swallowing DB errors) is the upstream cause and is worth fixing on
its own; this issue is the missing guard that turns it into an auth bypass.