From 9d971fad56d6571a3bfc037ca21e3be694b9cabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Padilla?= Date: Thu, 21 May 2026 14:11:10 -0400 Subject: [PATCH] Bundle security fixes and hardening into 2.13.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - `HMACAlgorithm.prepare_key` rejects JWK JSON documents passed as raw HMAC secrets to close an algorithm-confusion gap not covered by the existing PEM/SSH guard. Reported by @aradona91 in GHSA-xgmm-8j9v-c9wx. - Bind the JWT header `alg` to `PyJWK.algorithm_name` during verification so the caller's `algorithms` allow-list cannot be bypassed when decoding with a `PyJWK` / `PyJWKClient` key. Reported by @sushi-gif in GHSA-jq35-7prp-9v3f. - Skip the unconditional base64 decode of the compact-form payload segment when `b64=false` is set, and require that segment to be empty (RFC 7515 Appendix F detached form). Closes an unauthenticated DoS amplifier. Reported by @thesmartshadow in GHSA-w7vc-732c-9m39. - `PyJWKClient` rejects any URI whose scheme is not `http` or `https` so attacker-influenced URIs cannot read local files or reach unintended schemes via urllib's default `file://` / `ftp://` / `data:` handlers. Reported by @KEIJOT in GHSA-993g-76c3-p5m4. - Preserve the cached JWK Set on fetch errors in `PyJWKClient.fetch_data`. The previous `finally`-block `put(None)` pattern cleared the cache on any transient outage. Reported by @eddieran in GHSA-fhv5-28vv-h8m8. Fixes: - Reject empty HMAC keys outright in `HMACAlgorithm.prepare_key` with `InvalidKeyError` instead of accepting them with only a warning. Hardening prompted by reports from @SnailSploit and @spartan8806. - Forward per-call `options` (including `enforce_minimum_key_length`) from `PyJWT.decode` through to `PyJWS._verify_signature`. Thanks to @WLUB. - RFC 7797 ยง3 compliance for `b64=false`: encoder auto-adds `"b64"` to `crit`; decoder rejects tokens that set `b64=false` without listing it in `crit`. Thanks to @MachineLearning-Nerd. CVE: CVE-2026-48524 Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/95791b1759b8aa4f2203575d344d5c78564cdc81] Backport Changes: - Split out the cached-JWK-set preservation fix because the upstream commit bundles multiple CVEs. - Omitted CHANGELOG.rst because it conflicted and is release documentation. - Omitted the 2.13.0 version bump and other bundled fixes; applicable CVE fixes are carried in separate patches. (cherry picked from commit 95791b1759b8aa4f2203575d344d5c78564cdc81) Signed-off-by: Hetvi Thakar --- jwt/jwks_client.py | 14 ++++++++------ tests/test_jwks_client.py | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index 8d1b0c4..41f333a 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -113,7 +113,6 @@ class PyJWKClient: :returns: The parsed JWK Set as a dictionary. :raises PyJWKClientConnectionError: If the HTTP request fails. """ - jwk_set: Any = None try: r = urllib.request.Request(url=self.uri, headers=self.headers) with urllib.request.urlopen( @@ -126,11 +125,14 @@ class PyJWKClient: raise PyJWKClientConnectionError( f'Fail to fetch data from the url, err: "{e}"' ) from e - else: - return jwk_set - finally: - if self.jwk_set_cache is not None: - self.jwk_set_cache.put(jwk_set) + + # Only update the cache on a successful fetch. Writing in a + # `finally` block with `jwk_set=None` on error clears any + # previously-cached JWKS, turning a transient outage into a cache + # wipe that breaks legitimate auth. + if self.jwk_set_cache is not None: + self.jwk_set_cache.put(jwk_set) + return jwk_set def get_jwk_set(self, refresh: bool = False) -> PyJWKSet: """Return the JWK Set, using the cache when available. diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index d6793cc..da68664 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -288,18 +288,30 @@ class TestPyJWKClient: assert repeated_call.call_count == 1 - def test_get_jwt_set_failed_request_should_clear_cache(self) -> None: + def test_get_jwt_set_failed_refresh_preserves_cached_jwks(self) -> None: + # Regression: a transient fetch failure used to clear the cache via + # the previous `finally: put(jwk_set=None)` pattern, turning one bad + # request from the JWKS endpoint into application-wide auth failure. + # The cache must survive. url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" jwks_client = PyJWKClient(url) with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID): jwks_client.get_jwk_set() + assert jwks_client.jwk_set_cache is not None + assert jwks_client.jwk_set_cache.get() is not None + with pytest.raises(PyJWKClientError): with mocked_failed_response(): jwks_client.get_jwk_set(refresh=True) - assert jwks_client.jwk_set_cache is None + cached = jwks_client.jwk_set_cache.get() + assert cached is not None + # Subsequent reads still serve from cache without another fetch. + with mocked_success_response(RESPONSE_DATA_WITH_MATCHING_KID) as call: + jwks_client.get_jwk_set() + assert call.call_count == 0 def test_failed_request_should_raise_connection_error(self) -> None: token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6Ik5FRTFRVVJCT1RNNE16STVSa0ZETlRZeE9UVTFNRGcyT0Rnd1EwVXpNVGsxUWpZeVJrUkZRdyJ9.eyJpc3MiOiJodHRwczovL2Rldi04N2V2eDlydS5hdXRoMC5jb20vIiwic3ViIjoiYVc0Q2NhNzl4UmVMV1V6MGFFMkg2a0QwTzNjWEJWdENAY2xpZW50cyIsImF1ZCI6Imh0dHBzOi8vZXhwZW5zZXMtYXBpIiwiaWF0IjoxNTcyMDA2OTU0LCJleHAiOjE1NzIwMDY5NjQsImF6cCI6ImFXNENjYTc5eFJlTFdVejBhRTJINmtEME8zY1hCVnRDIiwiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIn0.PUxE7xn52aTCohGiWoSdMBZGiYAHwE5FYie0Y1qUT68IHSTXwXVd6hn02HTah6epvHHVKA2FqcFZ4GGv5VTHEvYpeggiiZMgbxFrmTEY0csL6VNkX1eaJGcuehwQCRBKRLL3zKmA5IKGy5GeUnIbpPHLHDxr-GXvgFzsdsyWlVQvPX2xjeaQ217r2PtxDeqjlf66UYl6oY6AqNS8DH3iryCvIfCcybRZkc_hdy-6ZMoKT6Piijvk_aXdm7-QQqKJFHLuEqrVSOuBqqiNfVrG27QzAPuPOxvfXTVLXL2jek5meH6n-VWgrBdoMFH93QEszEDowDAEhQPHVs0xj7SIzA"