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:
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user