Skip to content

[Bug Report] CubeOps can boot with an empty JWT signing secret, allowing forged admin tokens #1373

Description

@dwin-gharibi

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).

  1. Deploy CubeOps without setting JWT_SECRET, so the secret is auto-generated into
    t_system_setting.

  2. 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';

  3. Restart CubeOps. It starts normally and logs
    JWT secret loaded from database (t_system_setting).

  4. 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(""))
  5. 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-28GetSystemSetting returns ("", nil) for a row
    that is missing, a value that is empty, and any DB error.
  • CubeOps/internal/store/setting.go:33-41GetOrCreateSystemSetting reads back through it.
  • CubeOps/internal/store/db.go:139-148BootstrapJWTSecret returns winner unchecked.
  • CubeOps/cmd/cubeops/main.go:53-58 — only err is checked.
  • CubeOps/internal/auth/jwt.go:54-60NewJWTManager 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.

Metadata

Metadata

Assignees

Labels

area/CubeMasterImpacts the CubeMatser (control plane)bugSomething isn't workingneeds-triage

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions