-
Notifications
You must be signed in to change notification settings - Fork 1k
cubeops: reject an empty JWT signing secret #1374
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
808f704
d612c33
9a5973c
c424a9a
334a57a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,6 +59,15 @@ func NewJWTManager(secret string, accessTTL, refreshTTL time.Duration) *JWTManag | |
| } | ||
| } | ||
|
|
||
| var errEmptyJWTSecret = errors.New("jwt: signing secret is empty") | ||
|
|
||
| func (m *JWTManager) checkSecret() error { | ||
| if len(m.secret) == 0 { | ||
| return errEmptyJWTSecret | ||
| } | ||
| return nil | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| // AccessTTL returns the configured access-token TTL. | ||
| func (m *JWTManager) AccessTTL() time.Duration { return m.accessTTL } | ||
|
|
||
|
|
@@ -67,6 +76,9 @@ func (m *JWTManager) RefreshTTL() time.Duration { return m.refreshTTL } | |
|
|
||
| // GenerateAccessToken creates a signed JWT access token. | ||
| func (m *JWTManager) GenerateAccessToken(username string) (string, error) { | ||
| if err := m.checkSecret(); err != nil { | ||
| return "", err | ||
| } | ||
| now := time.Now() | ||
| claims := AccessClaims{ | ||
| RegisteredClaims: jwt.RegisteredClaims{ | ||
|
|
@@ -86,6 +98,9 @@ func (m *JWTManager) GenerateAccessToken(username string) (string, error) { | |
|
|
||
| // GenerateRefreshToken creates a signed JWT refresh token. | ||
| func (m *JWTManager) GenerateRefreshToken(username string) (string, string, error) { | ||
| if err := m.checkSecret(); err != nil { | ||
| return "", "", err | ||
| } | ||
| now := time.Now() | ||
| tokenID := uuid.New().String() | ||
| claims := RefreshClaims{ | ||
|
|
@@ -111,6 +126,9 @@ func (m *JWTManager) GenerateRefreshToken(username string) (string, string, erro | |
| // tokens by checking the "typ" claim and the audience, so a long-lived | ||
| // refresh token cannot be used as an access token. | ||
| func (m *JWTManager) VerifyAccessToken(tokenStr string) (*AccessClaims, error) { | ||
| if err := m.checkSecret(); err != nil { | ||
| return nil, err | ||
| } | ||
| token, err := jwt.ParseWithClaims(tokenStr, &AccessClaims{}, func(t *jwt.Token) (interface{}, error) { | ||
| if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { | ||
| return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) | ||
|
|
@@ -136,6 +154,9 @@ func (m *JWTManager) VerifyAccessToken(tokenStr string) (*AccessClaims, error) { | |
| // than on this package's internals. It rejects access tokens via the "typ" | ||
| // claim and the audience. | ||
| func (m *JWTManager) VerifyRefreshToken(tokenStr string) (*service.RefreshClaims, error) { | ||
| if err := m.checkSecret(); err != nil { | ||
| return nil, err | ||
| } | ||
| token, err := jwt.ParseWithClaims(tokenStr, &RefreshClaims{}, func(t *jwt.Token) (interface{}, error) { | ||
| if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { | ||
| return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| // Copyright (c) 2026 Tencent Inc. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| package auth_test | ||
|
|
||
| import ( | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/tencentcloud/CubeSandbox/CubeOps/internal/auth" | ||
| ) | ||
|
|
||
| func TestEmptySecretRejectedOnGenerate(t *testing.T) { | ||
| jm := auth.NewJWTManager("", 15*time.Minute, 168*time.Hour) | ||
|
|
||
| if _, err := jm.GenerateAccessToken("admin"); err == nil { | ||
| t.Fatal("GenerateAccessToken accepted an empty signing secret") | ||
| } | ||
| if _, _, err := jm.GenerateRefreshToken("admin"); err == nil { | ||
| t.Fatal("GenerateRefreshToken accepted an empty signing secret") | ||
| } | ||
| } | ||
|
|
||
| func TestEmptySecretRejectedOnVerify(t *testing.T) { | ||
| signer := auth.NewJWTManager("forged-secret-32-bytes-long-ok!!", 15*time.Minute, 168*time.Hour) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
To lock in the fix, mint a token with the empty key directly (bypassing the generator guard, e.g. |
||
| minted, err := signer.GenerateAccessToken("admin") | ||
| if err != nil { | ||
| t.Fatalf("failed to mint a token for the test: %v", err) | ||
| } | ||
|
|
||
| empty := auth.NewJWTManager("", 15*time.Minute, 168*time.Hour) | ||
| if _, err := empty.VerifyAccessToken(minted); err == nil { | ||
| t.Fatal("VerifyAccessToken accepted a token while configured with an empty secret") | ||
| } | ||
| if _, err := empty.VerifyRefreshToken(minted); err == nil { | ||
| t.Fatal("VerifyRefreshToken accepted a token while configured with an empty secret") | ||
| } | ||
| } | ||
|
|
||
| func TestNonEmptySecretStillWorks(t *testing.T) { | ||
| jm := auth.NewJWTManager("good-secret-32-bytes-long-enough!", 15*time.Minute, 168*time.Hour) | ||
|
|
||
| access, err := jm.GenerateAccessToken("admin") | ||
| if err != nil { | ||
| t.Fatalf("GenerateAccessToken: %v", err) | ||
| } | ||
| claims, err := jm.VerifyAccessToken(access) | ||
| if err != nil { | ||
| t.Fatalf("VerifyAccessToken: %v", err) | ||
| } | ||
| if claims.Username != "admin" { | ||
| t.Fatalf("Username = %q, want admin", claims.Username) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ package store | |
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
|
|
||
| "github.com/tencentcloud/CubeSandbox/CubeDB/dao" | ||
|
|
@@ -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) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if winner == "" { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Consider adding a store-level test using the existing dockertest fixture: insert a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: this branch also fires when the read-back inside |
||
| return "", errors.New("JWT secret resolved to an empty value in t_system_setting") | ||
| } | ||
| if winner == generated { | ||
| logging.G(ctx).Info("JWT secret auto-generated and persisted to database (t_system_setting)") | ||
| } else { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
len(m.secret) == 0only catches a fully-empty key. A whitespace-only secret (e.g.JWT_SECRET=" "via env orjwt_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.