fix(auth): verify JWT signature to prevent cache-key forgery (GHSA-9gqw-h2rw-44wv)

The proxy derived per-user GraphQL cache keys from JWT claims without
verifying the token signature (CWE-345). A remote attacker could forge a
token that carried a victim's claim value and read the victim's cached
response.

This change adds opt-in signature verification behind JWT_VERIFY_SIGNATURE.
The flag defaults to false, so an upgrade without a config change keeps the
previous delegate-to-upstream behavior byte for byte.

When verification is on, the operator must set exactly one key source:
JWT_SECRET, JWT_PUBLIC_KEY, or JWT_JWKS_URL. The verifier pins the accepted
algorithms to the key family through WithValidMethods and a type-switched
keyfunc, and it refuses alg=none. This closes the algorithm-confusion class
of attack. A present but invalid token now returns 401. A missing
Authorization header stays anonymous.

Startup fails loudly when verification is on and the key source is absent or
unreadable, so the proxy can never boot half-configured.
This commit is contained in:
2026-08-17 13:51:00 +01:00
parent 99f0c844f0
commit 8258b3d99a
11 changed files with 881 additions and 36 deletions
+33 -8
View File
@@ -15,31 +15,56 @@ const defaultValue = "-"
var emptyMetrics = map[string]string{}
func extractClaimsFromJWTHeader(authorization string) (usr, role string) {
// extractClaimsFromJWTHeader extracts the user ID and role claim from an
// Authorization header value.
//
// When cfg.Client.JWTVerifier is set (JWT_VERIFY_SIGNATURE=true), the token
// signature is checked first; a signature, expiry, or algorithm failure
// returns a non-nil err and the caller must not trust usr/role (this is the
// fix for GHSA-9gqw-h2rw-44wv, where a forged token with a guessed claim
// could otherwise read another user's cached response).
//
// When JWTVerifier is nil (the default), this falls back to the legacy
// path: the payload is decoded without checking its signature, exactly as
// before this field existed. err is always nil on that path, matching
// pre-verification behaviour byte for byte.
func extractClaimsFromJWTHeader(authorization string) (usr, role string, err error) {
usr, role = defaultValue, defaultValue
if cfg.Client.JWTVerifier != nil {
claimMap, verr := cfg.Client.JWTVerifier.Verify(authorization)
if verr != nil {
handleError("JWT verification failed", map[string]any{"token": maskToken(authorization)})
return defaultValue, defaultValue, verr
}
usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id")
role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role")
return usr, role, nil
}
tokenParts := strings.SplitN(authorization, ".", 3)
if len(tokenParts) != 3 {
handleError("Can't split the token", map[string]any{"token": maskToken(authorization)})
return
return usr, role, nil
}
claim, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
if err != nil {
claim, decErr := base64.RawURLEncoding.DecodeString(tokenParts[1])
if decErr != nil {
handleError("Can't decode the token", map[string]any{"token": maskToken(authorization)})
return
return usr, role, nil
}
var claimMap map[string]any
if err = json.Unmarshal(claim, &claimMap); err != nil {
if jsonErr := json.Unmarshal(claim, &claimMap); jsonErr != nil {
handleError("Can't unmarshal the claim", map[string]any{"token": maskToken(authorization)})
return
return usr, role, nil
}
usr = extractClaim(claimMap, cfg.Client.JWTUserClaimPath, "user id")
role = extractClaim(claimMap, cfg.Client.JWTRoleClaimPath, "role")
return
return usr, role, nil
}
func extractClaim(claimMap map[string]any, claimPath, name string) string {