From f369d32169410b17717d2a9c00d5bf3ac655c85d 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-48522 Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/95791b1759b8aa4f2203575d344d5c78564cdc81] Backport Changes: - Split out the PyJWKClient URI-scheme validation 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 | 11 +++++++++++ tests/test_jwks_client.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/jwt/jwks_client.py b/jwt/jwks_client.py index b81e8f4..8d1b0c4 100644 --- a/jwt/jwks_client.py +++ b/jwt/jwks_client.py @@ -6,6 +6,7 @@ from functools import lru_cache from ssl import SSLContext from typing import Any from urllib.error import HTTPError, URLError +from urllib.parse import urlparse from .api_jwk import PyJWK, PyJWKSet from .api_jwt import decode_complete as decode_token @@ -69,6 +70,16 @@ class PyJWKClient: """ if headers is None: headers = {} + # urllib's default OpenerDirector also handles file://, ftp://, and + # data: URIs. Reject anything that isn't http(s) eagerly so a caller + # passing an attacker-influenced URL (e.g. taken from a `jku` token + # header) can't read local files or reach other unintended schemes. + scheme = urlparse(uri).scheme.lower() + if scheme not in ("http", "https"): + raise PyJWKClientError( + f"Invalid JWKS URI scheme {scheme!r}: only 'http' and 'https' " + f"are supported." + ) self.uri = uri self.jwk_set_cache: JWKSetCache | None = None self.headers = headers diff --git a/tests/test_jwks_client.py b/tests/test_jwks_client.py index ceee672..d6793cc 100644 --- a/tests/test_jwks_client.py +++ b/tests/test_jwks_client.py @@ -344,6 +344,36 @@ class TestPyJWKClient: jwks_client = PyJWKClient(url, lifespan=-1) assert jwks_client is None + @pytest.mark.parametrize( + "uri", + [ + "file:///etc/passwd", + "ftp://example.org/keys.json", + 'data:application/json,{"keys":[]}', + "/etc/passwd", # urlparse gives scheme="" — also rejected + "ldap://internal.test/jwks", + ], + ) + def test_pyjwkclient_rejects_non_http_schemes(self, uri: str) -> None: + # urllib's default OpenerDirector handles file://, ftp://, and data: + # URIs. PyJWKClient must reject these so callers can't be tricked + # into reading attacker-controlled local files or other unintended + # schemes via a manipulated URI. + with pytest.raises(PyJWKClientError, match="Invalid JWKS URI scheme"): + PyJWKClient(uri) + + @pytest.mark.parametrize( + "uri", + [ + "http://localhost/jwks.json", + "https://example.test/jwks.json", + "HTTPS://Example.Test/jwks.json", # case-insensitive + ], + ) + def test_pyjwkclient_accepts_http_https_schemes(self, uri: str) -> None: + # Construction succeeds; no fetch is made until get_jwk_set(). + PyJWKClient(uri) + def test_get_jwt_set_timeout(self) -> None: url = "https://dev-87evx9ru.auth0.com/.well-known/jwks.json" jwks_client = PyJWKClient(url, timeout=5)