From e6152fb27dd439bf4541aab9b26a93134c48135f 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-48525 Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/95791b1759b8aa4f2203575d344d5c78564cdc81] Backport Changes: - Split out the b64=false payload-decoding DoS fix because the upstream commit bundles multiple CVEs. - Omitted CHANGELOG.rst because it conflicted and is release documentation. - Carried the focused `test_decode_b64_false_rejects_non_empty_payload_segment` regression test. Omitted other tests/test_api_jws.py hunks for unrelated bundled fixes. - 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/api_jws.py | 22 ++++++++++++++++++---- tests/test_api_jws.py | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/jwt/api_jws.py b/jwt/api_jws.py index 91d2ae3..5a91bce 100644 --- a/jwt/api_jws.py +++ b/jwt/api_jws.py @@ -317,10 +317,24 @@ class PyJWS: if not isinstance(header, dict): raise DecodeError("Invalid header string: must be a json object") - try: - payload = base64url_decode(payload_segment) - except (TypeError, binascii.Error) as err: - raise DecodeError("Invalid payload padding") from err + if header.get("b64", True) is False: + # Detached payload form (RFC 7515 Appendix F): the compact-form + # payload segment must be empty; the caller supplies the actual + # payload via the `detached_payload` argument in decode_complete. + # Skipping the base64 decode here removes an unauthenticated work + # amplifier — otherwise an attacker can inflate the unused + # segment to force CPU + memory cost before the signature is + # even checked. + if payload_segment: + raise DecodeError( + "Payload segment must be empty when 'b64' is false." + ) + payload = b"" + else: + try: + payload = base64url_decode(payload_segment) + except (TypeError, binascii.Error) as err: + raise DecodeError("Invalid payload padding") from err try: signature = base64url_decode(crypto_segment) diff --git a/tests/test_api_jws.py b/tests/test_api_jws.py index 0715b9e..01de813 100644 --- a/tests/test_api_jws.py +++ b/tests/test_api_jws.py @@ -12,7 +12,7 @@ from jwt.exceptions import ( InvalidSignatureError, InvalidTokenError, ) -from jwt.utils import base64url_decode +from jwt.utils import base64url_decode, base64url_encode from jwt.warnings import RemovedInPyjwt3Warning from .utils import crypto_required, key_path, no_crypto_required @@ -984,6 +984,40 @@ class TestJWS: assert "b64" not in msg_header_obj assert msg_payload + def test_decode_b64_false_rejects_non_empty_payload_segment( + self, jws: PyJWS, payload: bytes + ) -> None: + # RFC 7515 Appendix F detached form: when b64=false, the compact- + # serialization payload segment must be empty. PyJWT must reject a + # non-empty middle segment without doing any base64-decoding work + # on it — that decode used to be the unauthenticated DoS amplifier. + secret = "secret" + import hmac as _hmac + import hashlib as _hashlib + + header_obj = { + "typ": "JWT", + "alg": "HS256", + "b64": False, + "crit": ["b64"], + } + header_b64 = base64url_encode( + json.dumps(header_obj, separators=(",", ":")).encode() + ) + # Stuff the middle segment with arbitrary attacker-controlled bytes. + # This should be rejected without being base64-decoded. + attacker_segment = b"A" * 1024 + signing_input = b".".join([header_b64, payload]) + sig = _hmac.new(secret.encode(), signing_input, _hashlib.sha256).digest() + token = b".".join( + [header_b64, attacker_segment, base64url_encode(sig)] + ).decode() + + with pytest.raises(DecodeError, match="Payload segment must be empty"): + jws.decode( + token, secret, algorithms=["HS256"], detached_payload=payload + ) + def test_decode_detached_content_without_proper_argument(self, jws: PyJWS) -> None: example_jws = ( "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImI2NCI6ZmFsc2V9"