diff --git a/jose/jws.py b/jose/jws.py index 27f6b79..cc6c9ff 100644 --- a/jose/jws.py +++ b/jose/jws.py @@ -74,6 +74,7 @@ def verify(token, key, algorithms, verify=True): header, payload, signing_input, signature = _load(token) if verify: + _validate_crit(header) _verify_signature(signing_input, header, signature, key, algorithms) return payload @@ -206,6 +207,40 @@ def _load(jwt): return (header, payload, signing_input, signature) +# Header parameter names that may legitimately appear in a "crit" list and +# that this library understands. RFC 7515 forbids the standard header +# parameters defined by RFC 7515/7518 from being listed in "crit", so this set +# is intentionally empty: python-jose does not implement any of the optional +# extensions (for example "b64" from RFC 7797) that "crit" is meant to flag. +_UNDERSTOOD_CRIT_HEADERS = frozenset() + + +def _validate_crit(header): + """Enforce RFC 7515 section 4.1.11 "crit" (Critical) header handling. + + A JWS whose protected header lists a critical extension that the verifier + does not understand MUST be rejected (fail closed). python-jose implements + none of the optional "crit" extensions, so any well-formed, non-empty + "crit" list is unsupported. Malformed "crit" values are also rejected. + """ + if "crit" not in header: + return + + crit = header["crit"] + + # Per RFC 7515, "crit" must be an array of non-empty case-sensitive strings. + if not isinstance(crit, list) or not crit: + raise JWSError("Invalid crit header: must be a non-empty list of strings") + + for name in crit: + if not isinstance(name, str) or not name: + raise JWSError("Invalid crit header: must be a non-empty list of strings") + + unsupported = [name for name in crit if name not in _UNDERSTOOD_CRIT_HEADERS] + if unsupported: + raise JWSError("Unsupported crit header parameter(s): %s" % ", ".join(unsupported)) + + def _sig_matches_keys(keys, signing_input, signature, alg): for key in keys: if not isinstance(key, Key): diff --git a/tests/test_jws.py b/tests/test_jws.py index 1609b6b..54e97e5 100644 --- a/tests/test_jws.py +++ b/tests/test_jws.py @@ -90,6 +90,64 @@ def test_round_trip_with_different_key_types(self, key): assert verified_data["testkey"] == "testvalue" +class TestCrit: + """RFC 7515 section 4.1.11 "crit" (Critical) header enforcement.""" + + def _make_token(self, secret, headers): + import base64 + + header = {"alg": "HS256", "typ": "JWT"} + header.update(headers) + header_segment = base64.urlsafe_b64encode(json.dumps(header, separators=(",", ":")).encode("utf-8")).rstrip( + b"=" + ) + claims_segment = base64.urlsafe_b64encode(b'{"a":"b"}').rstrip(b"=") + signing_input = header_segment + b"." + claims_segment + + from jose import jwk + + key = jwk.construct(secret, "HS256") + signature = base64.urlsafe_b64encode(key.sign(signing_input)).rstrip(b"=") + return (signing_input + b"." + signature).decode("utf-8") + + def test_unsupported_crit_extension_is_rejected(self): + # A correctly signed token whose protected header declares an + # unsupported critical extension must fail closed (RFC 7515 4.1.11). + token = self._make_token( + "secret", + {"crit": ["x-custom-policy"], "x-custom-policy": "require-mfa"}, + ) + with pytest.raises(JWSError): + jws.verify(token, "secret", ["HS256"]) + + def test_token_without_crit_still_verifies(self): + token = self._make_token("secret", {}) + assert jws.verify(token, "secret", ["HS256"]) == b'{"a":"b"}' + + def test_crit_not_a_list_is_rejected(self): + token = self._make_token("secret", {"crit": "x-custom-policy"}) + with pytest.raises(JWSError): + jws.verify(token, "secret", ["HS256"]) + + def test_empty_crit_list_is_rejected(self): + token = self._make_token("secret", {"crit": []}) + with pytest.raises(JWSError): + jws.verify(token, "secret", ["HS256"]) + + def test_crit_with_non_string_entry_is_rejected(self): + token = self._make_token("secret", {"crit": ["x-custom-policy", 1]}) + with pytest.raises(JWSError): + jws.verify(token, "secret", ["HS256"]) + + def test_crit_skipped_when_verify_disabled(self): + # verify=False should not enforce crit, mirroring signature skipping. + token = self._make_token( + "secret", + {"crit": ["x-custom-policy"], "x-custom-policy": "require-mfa"}, + ) + assert jws.verify(token, "secret", ["HS256"], verify=False) == b'{"a":"b"}' + + class TestJWK: def test_jwk(self, payload): key_data = "key"