Skip to content

cubeops: reject an empty JWT signing secret - #1374

Open
dwin-gharibi wants to merge 5 commits into
TencentCloud:masterfrom
dwin-gharibi:fix/cubeops-empty-jwt-secret
Open

cubeops: reject an empty JWT signing secret#1374
dwin-gharibi wants to merge 5 commits into
TencentCloud:masterfrom
dwin-gharibi:fix/cubeops-empty-jwt-secret

Conversation

@dwin-gharibi

Copy link
Copy Markdown

Closes #1373.

Motivation

BootstrapJWTSecret could hand back an empty string with a nil error, after which
NewJWTManager stored a zero-length HMAC key. golang-jwt/v5 treats []byte{} as a perfectly
valid HMAC key, so anyone who guessed the secret was empty could mint a sub=admin,
aud=cubeops:access, typ=access token that passed VerifyAccessToken unchanged — full access to
every /api/v1 route behind auth.Middleware.

The default one-click deployment is the affected configuration: it deliberately leaves JWT_SECRET
unset so the secret is auto-generated into t_system_setting, which is the code path that can
return "".

What this changes

Two independent guards, so neither alone has to be perfect:

  1. CubeOps/internal/store/db.goBootstrapJWTSecret now fails when the resolved secret is
    empty instead of returning it as a success. main.go already exits on that error, so startup
    aborts with a clear message rather than coming up insecure.

  2. CubeOps/internal/auth/jwt.goJWTManager refuses a zero-length key in all four token
    operations (GenerateAccessToken, GenerateRefreshToken, VerifyAccessToken,
    VerifyRefreshToken) via a shared checkSecret() helper. This keeps the exported
    NewJWTManager signature unchanged while making forged-token acceptance impossible even if an
    empty secret reaches the manager some other way.

Deliberately not changed here:

  • GetSystemSetting / GetSetting / GetUserPassword still swallow DB errors. That is the
    upstream cause and is a behaviour change with a wider blast radius, so it is a separate fix
    (see the sibling branch for report finding ci: add builder image workflow and update protobuf tooling #5). This PR is safe to land on its own and does not
    depend on it.
  • No comment changes; no unrelated cleanup.

Testing

New: CubeOps/internal/auth/jwt_empty_secret_test.go

  • TestEmptySecretRejectedOnGenerate — both generators refuse an empty key.
  • TestEmptySecretRejectedOnVerify — both verifiers refuse a token when configured with an empty
    key, including a token that was legitimately signed with a different, non-empty key.
  • TestNonEmptySecretStillWorks — regression guard that the normal sign/verify round trip is
    untouched.
$ cd CubeOps && go test ./...
ok  github.com/tencentcloud/CubeSandbox/CubeOps/internal/auth      3.025s
ok  github.com/tencentcloud/CubeSandbox/CubeOps/internal/store     57.508s
ok  github.com/tencentcloud/CubeSandbox/CubeOps/internal/service   8.063s
... 11 packages ok, 0 failures

CI gates checked locally:

  • gofmt -l ./internal ./cmd — clean (fmt-check).
  • go build ./... — clean.
  • go test ./... — 11 ok, 0 failures (unit-test-check runs make cubeops-test, which is
    go test ./... -v -count=1).

Risk / rollout

Low risk, but it is a fail-closed change: an operator whose jwt_secret row is currently empty
will see CubeOps refuse to start instead of silently accepting forged tokens. That is the intended
behaviour. The remedy is to clear the row so it is regenerated, or to set JWT_SECRET explicitly:

DELETE FROM t_system_setting WHERE setting_key = 'jwt_secret';

Existing valid sessions are unaffected — the secret itself is not rotated by this change.

Copilot AI lite review requested due to automatic review settings August 18, 2026 05:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

}

func TestEmptySecretRejectedOnVerify(t *testing.T) {
signer := auth.NewJWTManager("forged-secret-32-bytes-long-ok!!", 15*time.Minute, 168*time.Hour)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test does not actually exercise the empty-secret guard on the verify path — it would pass even if checkSecret() were removed from VerifyAccessToken/VerifyRefreshToken.

minted is signed with the non-empty key "forged-secret-32-bytes-long-ok!!", so when the empty-key manager parses it, the HMAC signature check itself fails (key mismatch) and err != nil regardless of the guard. The test is effectively a cross-key rejection test, not an empty-secret rejection test. It therefore gives no regression coverage for the verify-side guard — which is exactly the layer that stops forged tokens at the middleware (the attack surface described in #1373).

To lock in the fix, mint a token with the empty key directly (bypassing the generator guard, e.g. jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(""))) and assert the empty-secret manager rejects it. Before the fix such a token verifies successfully; after the fix checkSecret() rejects it — that is the precise forged-token scenario.

Comment thread CubeOps/internal/store/db.go Outdated
if err != nil {
return "", fmt.Errorf("persist JWT secret: %w", err)
}
if winner == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary fix for #1373 (startup aborts when the resolved secret is empty) has no test coverage. A regression — e.g. reverting this winner == "" check — would not be caught by CI, since all the new tests live in internal/auth and only cover the defense-in-depth layer.

Consider adding a store-level test using the existing dockertest fixture: insert a t_system_setting row for jwt_secret with an empty setting_value, then assert BootstrapJWTSecret(ctx, "") returns an error (and that main.go would consequently abort). That directly pins the reported vulnerability.

@cubesandboxbot

cubesandboxbot Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review: cubeops: reject an empty JWT signing secret (PR #1374)

Verdict: Approve. The vulnerability is real and correctly characterized — golang-jwt/v5 treats []byte{} as a valid HS256 key, so when BootstrapJWTSecret returned "" with a nil error, an attacker who guessed the empty secret could mint a sub=admin / aud=cubeops:access / typ=access token that passed VerifyAccessToken unchanged. The two-guard design (fail-closed JWTManager + DB-side repair/error in BootstrapJWTSecret) is sound defense-in-depth, the tests are meaningful regression guards, and no unrelated behavior is changed. I verified the full call path: main.go:72 feeds the bootstrapped secret back into cfg.JWTSecret, server.New constructs the manager from it, and all sign/verify operations live in internal/auth/jwt.go and are covered by checkSecret.

Findings below are minor; none block this landing.

F1 (low) — Whitespace-only JWT_SECRET bypasses the bootstrap fail-fast

checkSecret treats whitespace-only secrets as empty (strings.TrimSpace(string(m.secret)) == ""), but the sibling guard in BootstrapJWTSecret uses if envSecret != "" with no trimming (config.Load reads JWT_SECRET raw). A JWT_SECRET that resolves to spaces (e.g. JWT_SECRET=" ") is therefore returned from bootstrap as a nil-error success: the server starts, the DB row is never repaired, and every token operation fails at runtime. This is fail-closed (no forged-token acceptance) but contradicts the PR's stated "startup aborts with a clear message" guarantee, and the two guards disagree on the definition of "empty". Suggest strings.TrimSpace(envSecret) != "" in BootstrapJWTSecret or trimming at config load. [posted inline on jwt.go]

F2 (low) — PR description vs. actual behavior: auto-repair, not fail-closed refusal

The description says BootstrapJWTSecret "now fails when the resolved secret is empty" and that an operator with an empty jwt_secret row "will see CubeOps refuse to start". The code actually auto-repairs the row (conditional UPDATE to a freshly generated value) and starts successfully; startup only aborts when the repair cannot take. This is arguably better UX than described, but the description should be updated. Relatedly, "Existing valid sessions are unaffected" is not strictly accurate for the affected (empty-secret) configuration: tokens minted during the vulnerable window — which were forgeable anyway — are invalidated once the row is repaired to a real secret. That is the intended outcome, just worth stating precisely.

F3 (low) — Whitespace-only stored value can't be auto-repaired

repairEmptySystemSetting matches setting_value IS NULL OR setting_value = '', but the caller detects emptiness with strings.TrimSpace(winner) == "". A stored value of ' '/'\t' is detected as empty yet never matched by the UPDATE, so the repair is a no-op and bootstrap returns the hard error. Fail-closed and the error message is actionable, but the repair is inconsistent with the detection; TRIM(setting_value) = '' in the WHERE would align them. [posted inline on setting.go]

Test-coverage notes (informational)

  • The store tests use the existing Docker/MySQL fixture (newTestStore), so they skip without Docker/CI — consistent with the rest of the package.
  • repairEmptySystemSetting's NULL branch is untested (only the '' case is); a NULL row is the other trigger worth covering.
  • The whitespace-only env-secret path (F1) is not exercised anywhere.

Minor / maintainability

  • checkSecret is enforced only in the four current methods. Any future method that touches m.secret must remember to call it; a constructor-time or single-chokepoint validation would make this structurally harder to miss (the PR chose signature stability over that, which is a reasonable trade-off).
  • The winner == generated logging branch and the repair branch are mutually exclusive and each logs correctly; no issue found there.
  • The conditional UPDATE ... WHERE (setting_value IS NULL OR setting_value = '') correctly avoids clobbering a concurrent healthy write; under MySQL/PG semantics concurrent repairs converge on a single committed value, so I found no divergence race in the repair path.

AI-generated review — no human approval implied.

…th the empty key and cover the bootstrap guard
}
}

func TestTokenSignedWithADifferentSecretIsRejected(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes with or without the new checkSecret() guard, so it doesn't actually regress-test this fix. minted is signed with a non-empty key, so empty.VerifyAccessToken(minted) already fails via an HMAC signature mismatch in jwt.ParseWithClaims — even if the empty-secret guard were removed, this assertion still holds. (The test that genuinely pins the fix is TestForgedTokenSignedWithEmptySecretIsRejected, where the token is signed with the empty key.) It also overlaps with the existing TestS1_DifferentSecretsRejected in jwt_test.go. Suggest dropping it, or — if you want to pin the guard itself — assert the rejection is the empty-secret error (e.g. strings.Contains(err.Error(), "empty")) rather than relying on the signature mismatch, so the test fails if the guard is ever removed.

Comment thread CubeOps/internal/store/db.go Outdated
if err != nil {
return "", fmt.Errorf("persist JWT secret: %w", err)
}
if winner == "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this branch also fires when the read-back inside GetOrCreateSystemSetting fails with a real DB error, not just when the stored value is genuinely empty. GetSystemSetting returns ("", nil) whenever the scanned value is empty — even if the SELECT itself errored (the error-swallowing this PR scopes out). So a transient connection drop between the INSERT IGNORE and the read-back surfaces as "JWT secret resolved to an empty value in t_system_setting" instead of the underlying DB error. Still fail-closed (safe direction), but the message could mislead an operator debugging a startup failure. Worth a comment noting the conflation, or fixing once GetSystemSetting stops swallowing errors.

@@ -140,6 +141,9 @@ func (s *Store) BootstrapJWTSecret(ctx context.Context, envSecret string) (strin
if err != nil {
return "", fmt.Errorf("persist JWT secret: %w", err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consideration (not a blocker — the PR documents fail-closed as intentional): when the stored row is empty, a fresh non-empty generated secret is already in hand. Repairing the row with SetSystemSetting(ctx, "jwt_secret", generated) and continuing would (a) un-break the one-click deploy this PR itself identifies as the affected configuration, and (b) remain secure — any tokens forged with the old empty key would fail verification against the new non-empty key. The chosen path instead forces manual SQL (DELETE FROM t_system_setting WHERE setting_key = 'jwt_secret') on the default deployment. Worth confirming self-healing was weighed, since it would be both safer-by-default and non-breaking here.


var errEmptyJWTSecret = errors.New("jwt: signing secret is empty")

func (m *JWTManager) checkSecret() error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: len(m.secret) == 0 only catches a fully-empty key. A whitespace-only secret (e.g. JWT_SECRET=" " via env or jwt_secret: " " in YAML) passes the guard while being trivially guessable — effectively a single-value keyspace. If the intent is to reject degenerate signing keys, strings.TrimSpace(string(m.secret)) == "" would cover both cases.

if strings.TrimSpace(string(m.secret)) == "" {
return errEmptyJWTSecret
}
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkSecret treats whitespace-only as empty, but the sibling guard in BootstrapJWTSecret (if envSecret != "") does not trim. A JWT_SECRET that resolves to spaces (e.g. JWT_SECRET=" " in a .env file) is returned from bootstrap as a nil-error success, so the server starts, the DB row is never repaired, and every token operation then fails at runtime with errEmptyJWTSecret. That undercuts the PR's stated fail-fast guarantee ("startup aborts with a clear message") for this input, and the two guards disagree on what "empty" means. Suggest if strings.TrimSpace(envSecret) != "" in BootstrapJWTSecret (or trimming at config load) so both defenses agree. Fail-closed, low severity — not a security hole, since the runtime check still rejects.


func (s *Store) repairEmptySystemSetting(ctx context.Context, key, value string) error {
return s.db.WithContext(ctx).Exec(
"UPDATE t_system_setting SET setting_value = ? WHERE setting_key = ? AND (setting_value IS NULL OR setting_value = '')",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller detects "empty" with strings.TrimSpace(winner) == "", but this WHERE only matches NULL and ''. A stored value of ' ' / '\t' is detected as empty by the caller yet never matched by this UPDATE, so the repair is a silent no-op and BootstrapJWTSecret then returns its hard error instead of the auto-repair path it logs about. It's fail-closed, so acceptable, but the repair is inconsistent with the detection. Using TRIM(setting_value) = '' in the WHERE would make them agree (and wouldn't match ' a ', only whitespace-only values). Minor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants