From 336f1a0c18c04b63cab024b9f030af2b6c6b248c 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-48526 Upstream-Status: Backport [https://github.com/jpadilla/pyjwt/commit/95791b1759b8aa4f2203575d344d5c78564cdc81] Backport Changes: - Split out the raw JWK-as-HMAC-secret rejection because the upstream commit bundles multiple CVEs. - Omitted CHANGELOG.rst because it conflicted and is release documentation. - Carried `test_hmac_prepare_key_rejects_jwk_json` and `test_hmac_prepare_key_accepts_json_without_kty` regression tests covering the same JWK-classification security boundary. - Omitted other tests/test_algorithms.py hunks for separate empty-key and per-call key-length hardening. - 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/algorithms.py | 20 ++++++++++++++++++++ tests/test_algorithms.py | 23 +++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/jwt/algorithms.py b/jwt/algorithms.py index 615dcf3..cf6da45 100644 --- a/jwt/algorithms.py +++ b/jwt/algorithms.py @@ -331,6 +331,26 @@ class HMACAlgorithm(Algorithm): " should not be used as an HMAC secret." ) + # Defense against algorithm-confusion attacks: an attacker with + # control over the token header can force this code path by setting + # alg=HS*, and HMACAlgorithm is the only algorithm that accepts + # arbitrary bytes as a valid secret. Other algorithms reject + # non-key-shaped input naturally. Even a symmetric (kty=oct) JWK + # should be loaded via PyJWK / from_jwk rather than fed as raw JSON + # bytes (whose contents are not the secret material). + stripped = key_bytes.lstrip() + if stripped.startswith(b"{"): + try: + jwk_obj = json.loads(key_bytes) + except ValueError: + jwk_obj = None + if isinstance(jwk_obj, dict) and "kty" in jwk_obj: + raise InvalidKeyError( + "The specified key looks like a JWK and should not be " + "used directly as an HMAC secret. Load it via " + "PyJWK / HMACAlgorithm.from_jwk first." + ) + return key_bytes @overload diff --git a/tests/test_algorithms.py b/tests/test_algorithms.py index 5449f3c..82659cf 100644 --- a/tests/test_algorithms.py +++ b/tests/test_algorithms.py @@ -122,6 +122,29 @@ class TestAlgorithms: with pytest.raises(InvalidKeyError): algo.from_jwk(keyfile.read()) + @pytest.mark.parametrize( + "jwk_file", + [ + "jwk_rsa_pub.json", + "jwk_ec_pub_P-256.json", + "jwk_okp_pub_Ed25519.json", + "jwk_hmac.json", + ], + ) + def test_hmac_prepare_key_rejects_jwk_json(self, jwk_file: str) -> None: + algo = HMACAlgorithm(HMACAlgorithm.SHA256) + + with open(key_path(jwk_file)) as keyfile: + with pytest.raises(InvalidKeyError, match="looks like a JWK"): + algo.prepare_key(keyfile.read()) + + def test_hmac_prepare_key_accepts_json_without_kty(self) -> None: + # JSON that doesn't look like a JWK (no "kty") should not be misclassified. + algo = HMACAlgorithm(HMACAlgorithm.SHA256) + + key = algo.prepare_key('{"this": "is just a json-shaped secret"}') + assert key == b'{"this": "is just a json-shaped secret"}' + @crypto_required def test_rsa_should_parse_pem_public_key(self) -> None: algo = RSAAlgorithm(RSAAlgorithm.SHA256)