Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions jose/jws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
58 changes: 58 additions & 0 deletions tests/test_jws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down