fix: GHSA-426f-p74m-73fv — JWT JWKS issuer confusion auth bypass (CVSS 9.4)

Pin issuer and JWKS URL at startup from CLERK_PUBLISHABLE_KEY.
Validate token iss claim before any JWKS fetch.
Add issuer= to jwt.decode() with verify_iss=True.
This commit is contained in:
ajaysi
2026-06-05 11:53:19 +05:30
committed by ي
parent 70542b32fc
commit b894bc0abb

View File

@@ -50,6 +50,7 @@ class ClerkAuthMiddleware:
# Cache for PyJWKClient to avoid repeated JWKS fetches # Cache for PyJWKClient to avoid repeated JWKS fetches
self._jwks_client_cache = {} self._jwks_client_cache = {}
self._jwks_url_cache = None self._jwks_url_cache = None
self._issuer_cache = None # Pre-configured Clerk issuer for iss validation
if not self.clerk_secret_key and not self.disable_auth: if not self.clerk_secret_key and not self.disable_auth:
logger.warning("CLERK_SECRET_KEY not found, authentication may fail") logger.warning("CLERK_SECRET_KEY not found, authentication may fail")
@@ -58,14 +59,15 @@ class ClerkAuthMiddleware:
if CLERK_AUTH_AVAILABLE and not self.disable_auth: if CLERK_AUTH_AVAILABLE and not self.disable_auth:
try: try:
if self.clerk_secret_key and self.clerk_publishable_key: if self.clerk_secret_key and self.clerk_publishable_key:
# Extract instance from publishable key for JWKS URL # Extract instance from publishable key for JWKS URL and issuer validation
# Format: pk_test_<instance>.<domain> or pk_live_<instance>.<domain> # Format: pk_test_<instance>.<domain> or pk_live_<instance>.<domain>
parts = self.clerk_publishable_key.replace('pk_test_', '').replace('pk_live_', '').split('.') parts = self.clerk_publishable_key.replace('pk_test_', '').replace('pk_live_', '').split('.')
if len(parts) >= 1: if len(parts) >= 1:
# Extract the domain from publishable key or use default # Extract the domain from publishable key or use default
# Clerk URLs are typically: https://<instance>.clerk.accounts.dev # Clerk URLs are typically: https://<instance>.clerk.accounts.dev
instance = parts[0] instance = parts[0]
jwks_url = f"https://{instance}.clerk.accounts.dev/.well-known/jwks.json" issuer_url = f"https://{instance}.clerk.accounts.dev"
jwks_url = f"{issuer_url}/.well-known/jwks.json"
# Create Clerk configuration with JWKS URL # Create Clerk configuration with JWKS URL
clerk_config = ClerkConfig( clerk_config = ClerkConfig(
@@ -76,6 +78,7 @@ class ClerkAuthMiddleware:
self.clerk_bearer = ClerkHTTPBearer(clerk_config) self.clerk_bearer = ClerkHTTPBearer(clerk_config)
logger.info(f"fastapi-clerk-auth initialized successfully with JWKS URL: {jwks_url}") logger.info(f"fastapi-clerk-auth initialized successfully with JWKS URL: {jwks_url}")
self._jwks_url_cache = jwks_url self._jwks_url_cache = jwks_url
self._issuer_cache = issuer_url # Pin issuer for VULN-001 fix
else: else:
logger.warning("Could not extract instance from publishable key") logger.warning("Could not extract instance from publishable key")
self.clerk_bearer = None self.clerk_bearer = None
@@ -118,19 +121,29 @@ class ClerkAuthMiddleware:
import jwt import jwt
from jwt import PyJWKClient from jwt import PyJWKClient
# Get the JWKS URL from the token header # Get the unverified header for key ID lookup
unverified_header = jwt.get_unverified_header(token) unverified_header = jwt.get_unverified_header(token)
# Decode token to get issuer for JWKS URL # --- SECURITY FIX (VULN-001): Validate issuer before any JWKS fetch ---
# Pre-configured issuer and JWKS URL derived from CLERK_PUBLISHABLE_KEY
# NEVER use the token's 'iss' claim to construct the JWKS URL (GHSA-426f-p74m-73fv)
expected_issuer = self._issuer_cache
jwks_url = self._jwks_url_cache
if not expected_issuer or not jwks_url:
raise Exception("Clerk issuer/JWKS URL not configured at startup")
# Decode token to validate the issuer claim against the pre-configured value
# WARNING: We must first validate 'iss' before trusting anything else
unverified_claims = jwt.decode(token, options={"verify_signature": False}) unverified_claims = jwt.decode(token, options={"verify_signature": False})
issuer = unverified_claims.get('iss', '') token_issuer = unverified_claims.get('iss', '')
if token_issuer != expected_issuer:
logger.error(
f"Issuer mismatch: token claims '{token_issuer}' "
f"but expected '{expected_issuer}'"
)
return None
# Construct JWKS URL from issuer # Use cached PyJWKClient with pinned jwks_url (never derived from token)
jwks_url = f"{issuer}/.well-known/jwks.json" if issuer else self._jwks_url_cache or ""
if not jwks_url:
raise Exception("Unable to resolve JWKS URL for Clerk verification")
# Use cached PyJWKClient to avoid repeated JWKS fetches
if jwks_url not in self._jwks_client_cache: if jwks_url not in self._jwks_client_cache:
logger.info(f"Creating new PyJWKClient for {jwks_url} with caching enabled") logger.info(f"Creating new PyJWKClient for {jwks_url} with caching enabled")
# Create client with caching enabled (cache_keys=True keeps keys in memory) # Create client with caching enabled (cache_keys=True keeps keys in memory)
@@ -145,11 +158,13 @@ class ClerkAuthMiddleware:
# Verify and decode the token with clock skew tolerance # Verify and decode the token with clock skew tolerance
# Add 300 seconds (5 minutes) leeway to handle clock skew and token refresh delays # Add 300 seconds (5 minutes) leeway to handle clock skew and token refresh delays
# SECURITY: Always pass issuer= to verify the token's 'iss' matches expected (VULN-001)
decoded_token = jwt.decode( decoded_token = jwt.decode(
token, token,
signing_key.key, signing_key.key,
algorithms=["RS256"], algorithms=["RS256"],
options={"verify_signature": True, "verify_exp": True}, issuer=expected_issuer,
options={"verify_signature": True, "verify_exp": True, "verify_iss": True},
leeway=300 # Allow 5 minutes leeway for token refresh during navigation leeway=300 # Allow 5 minutes leeway for token refresh during navigation
) )