Skip to content

Commit 051ea34

Browse files
authored
Merge commit from fork
Co-authored-by: José Padilla <[email protected]>
1 parent 1451d70 commit 051ea34

5 files changed

Lines changed: 148 additions & 3 deletions

File tree

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ This project adheres to `Semantic Versioning <https://semver.org/>`__.
77
`Unreleased <https://github.com/jpadilla/pyjwt/compare/2.11.0...HEAD>`__
88
------------------------------------------------------------------------
99

10+
`v2.12.0 <https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0>`__
11+
-----------------------------------------------------------------------
12+
1013
Fixed
1114
~~~~~
1215

1316
- Annotate PyJWKSet.keys for pyright by @tamird in `#1134 <https://github.com/jpadilla/pyjwt/pull/1134>`__
1417
- Close ``HTTPError`` response to prevent ``ResourceWarning`` on Python 3.14 by @veeceey in `#1133 <https://github.com/jpadilla/pyjwt/pull/1133>`__
1518
- Do not keep ``algorithms`` dict in PyJWK instances by @akx in `#1143 <https://github.com/jpadilla/pyjwt/pull/1143>`__
19+
- Validate the crit (Critical) Header Parameter defined in RFC 7515 §4.1.11. by @dmbs335 in `GHSA-752w-5fwx-jx9f <https://github.com/jpadilla/pyjwt/security/advisories/GHSA-752w-5fwx-jx9f>`__
1620

1721
Added
1822
~~~~~

jwt/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from .jwks_client import PyJWKClient
2929
from .warnings import InsecureKeyLengthWarning
3030

31-
__version__ = "2.11.0"
31+
__version__ = "2.12.0"
3232

3333
__title__ = "PyJWT"
3434
__description__ = "JSON Web Token implementation in Python"

jwt/api_jws.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ def encode(
150150
header: dict[str, Any] = {"typ": self.header_typ, "alg": algorithm_}
151151

152152
if headers:
153-
self._validate_headers(headers)
153+
self._validate_headers(headers, encoding=True)
154154
header.update(headers)
155155

156156
if not header["typ"]:
@@ -232,6 +232,8 @@ def decode_complete(
232232

233233
payload, signing_input, header, signature = self._load(jwt)
234234

235+
self._validate_headers(header)
236+
235237
if header.get("b64", True) is False:
236238
if detached_payload is None:
237239
raise DecodeError(
@@ -358,14 +360,35 @@ def _verify_signature(
358360
if not alg_obj.verify(signing_input, prepared_key, signature):
359361
raise InvalidSignatureError("Signature verification failed")
360362

361-
def _validate_headers(self, headers: dict[str, Any]) -> None:
363+
# Extensions that PyJWT actually understands and supports
364+
_supported_crit: set[str] = {"b64"}
365+
366+
def _validate_headers(
367+
self, headers: dict[str, Any], *, encoding: bool = False
368+
) -> None:
362369
if "kid" in headers:
363370
self._validate_kid(headers["kid"])
371+
if not encoding and "crit" in headers:
372+
self._validate_crit(headers)
364373

365374
def _validate_kid(self, kid: Any) -> None:
366375
if not isinstance(kid, str):
367376
raise InvalidTokenError("Key ID header parameter must be a string")
368377

378+
def _validate_crit(self, headers: dict[str, Any]) -> None:
379+
crit = headers["crit"]
380+
if not isinstance(crit, list) or len(crit) == 0:
381+
raise InvalidTokenError("Invalid 'crit' header: must be a non-empty list")
382+
for ext in crit:
383+
if not isinstance(ext, str):
384+
raise InvalidTokenError("Invalid 'crit' header: values must be strings")
385+
if ext not in self._supported_crit:
386+
raise InvalidTokenError(f"Unsupported critical extension: {ext}")
387+
if ext not in headers:
388+
raise InvalidTokenError(
389+
f"Critical extension '{ext}' is missing from headers"
390+
)
391+
369392

370393
_jws_global_obj = PyJWS()
371394
encode = _jws_global_obj.encode

tests/test_api_jws.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,3 +995,103 @@ def test_decode_complete_warns_on_unuspported_kwarg(
995995
]
996996
assert len(deprecation_warnings) == 1
997997
assert "foo" in str(deprecation_warnings[0].message)
998+
999+
def test_decode_rejects_unknown_crit_extension(
1000+
self, jws: PyJWS, payload: bytes
1001+
) -> None:
1002+
secret = "secret"
1003+
token = jws.encode(
1004+
payload,
1005+
secret,
1006+
algorithm="HS256",
1007+
headers={"crit": ["x-custom-policy"], "x-custom-policy": "require-mfa"},
1008+
)
1009+
1010+
with pytest.raises(InvalidTokenError, match="Unsupported critical extension"):
1011+
jws.decode(token, secret, algorithms=["HS256"])
1012+
1013+
def test_decode_rejects_empty_crit(self, jws: PyJWS, payload: bytes) -> None:
1014+
secret = "secret"
1015+
token = jws.encode(
1016+
payload,
1017+
secret,
1018+
algorithm="HS256",
1019+
headers={"crit": []},
1020+
)
1021+
1022+
with pytest.raises(InvalidTokenError, match="must be a non-empty list"):
1023+
jws.decode(token, secret, algorithms=["HS256"])
1024+
1025+
def test_decode_rejects_non_list_crit(self, jws: PyJWS, payload: bytes) -> None:
1026+
secret = "secret"
1027+
token = jws.encode(
1028+
payload,
1029+
secret,
1030+
algorithm="HS256",
1031+
headers={"crit": "b64"},
1032+
)
1033+
1034+
with pytest.raises(InvalidTokenError, match="must be a non-empty list"):
1035+
jws.decode(token, secret, algorithms=["HS256"])
1036+
1037+
def test_decode_rejects_crit_with_non_string_values(
1038+
self, jws: PyJWS, payload: bytes
1039+
) -> None:
1040+
secret = "secret"
1041+
token = jws.encode(
1042+
payload,
1043+
secret,
1044+
algorithm="HS256",
1045+
headers={"crit": [123]},
1046+
)
1047+
1048+
with pytest.raises(InvalidTokenError, match="values must be strings"):
1049+
jws.decode(token, secret, algorithms=["HS256"])
1050+
1051+
def test_decode_rejects_crit_extension_missing_from_header(
1052+
self, jws: PyJWS, payload: bytes
1053+
) -> None:
1054+
secret = "secret"
1055+
token = jws.encode(
1056+
payload,
1057+
secret,
1058+
algorithm="HS256",
1059+
headers={"crit": ["b64"]},
1060+
)
1061+
1062+
with pytest.raises(InvalidTokenError, match="missing from headers"):
1063+
jws.decode(token, secret, algorithms=["HS256"])
1064+
1065+
def test_decode_accepts_supported_crit_extension(
1066+
self, jws: PyJWS, payload: bytes
1067+
) -> None:
1068+
secret = "secret"
1069+
token = jws.encode(
1070+
payload,
1071+
secret,
1072+
algorithm="HS256",
1073+
headers={"crit": ["b64"], "b64": False},
1074+
is_payload_detached=True,
1075+
)
1076+
1077+
decoded = jws.decode(
1078+
token,
1079+
secret,
1080+
algorithms=["HS256"],
1081+
detached_payload=payload,
1082+
)
1083+
assert decoded == payload
1084+
1085+
def test_get_unverified_header_rejects_unknown_crit(
1086+
self, jws: PyJWS, payload: bytes
1087+
) -> None:
1088+
secret = "secret"
1089+
token = jws.encode(
1090+
payload,
1091+
secret,
1092+
algorithm="HS256",
1093+
headers={"crit": ["x-unknown"], "x-unknown": "value"},
1094+
)
1095+
1096+
with pytest.raises(InvalidTokenError, match="Unsupported critical extension"):
1097+
jws.get_unverified_header(token)

tests/test_api_jwt.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,3 +1065,21 @@ def test_validate_iss_with_non_str_issuer(self, jwt: PyJWT) -> None:
10651065
payload,
10661066
issuer=123, # type: ignore[arg-type]
10671067
)
1068+
1069+
# -------------------- Crit Header Tests --------------------
1070+
1071+
def test_decode_rejects_token_with_unknown_crit_extension(self, jwt: PyJWT) -> None:
1072+
"""RFC 7515 §4.1.11: tokens with unsupported critical extensions MUST be rejected."""
1073+
from jwt.exceptions import InvalidTokenError
1074+
1075+
secret = "secret"
1076+
payload = {"sub": "attacker", "role": "admin"}
1077+
token = jwt.encode(
1078+
payload,
1079+
secret,
1080+
algorithm="HS256",
1081+
headers={"crit": ["x-custom-policy"], "x-custom-policy": "require-mfa"},
1082+
)
1083+
1084+
with pytest.raises(InvalidTokenError, match="Unsupported critical extension"):
1085+
jwt.decode(token, secret, algorithms=["HS256"])

0 commit comments

Comments
 (0)