Skip to content

Commit 801cd12

Browse files
committed
fix: reject public JWK container HMAC keys
1 parent af8181c commit 801cd12

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

jwt/algorithms.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -367,15 +367,49 @@ def prepare_key(self, key: str | bytes) -> bytes:
367367
# should be loaded via PyJWK / from_jwk rather than fed as raw JSON
368368
# bytes (whose contents are not the secret material).
369369
try:
370-
jwk_obj = json.loads(key_bytes)
370+
jwk_obj = json.loads(key_bytes, parse_int=lambda _: 0)
371371
except RecursionError:
372372
try:
373373
decoded_key = key_bytes.decode(
374374
json.detect_encoding(key_bytes), errors="surrogatepass"
375375
)
376376
except UnicodeError:
377377
decoded_key = ""
378-
if decoded_key.lstrip().startswith("{"):
378+
stripped_key = decoded_key.lstrip("\ufeff \t\r\n")
379+
has_jwk_member = False
380+
index = 0
381+
while index < len(decoded_key):
382+
if decoded_key[index] != '"':
383+
index += 1
384+
continue
385+
end = index + 1
386+
while end < len(decoded_key):
387+
if decoded_key[end] == "\\":
388+
end += 2
389+
elif decoded_key[end] == '"':
390+
break
391+
else:
392+
end += 1
393+
if end >= len(decoded_key):
394+
break
395+
next_index = end + 1
396+
while next_index < len(decoded_key) and decoded_key[
397+
next_index
398+
] in " \t\r\n":
399+
next_index += 1
400+
if next_index < len(decoded_key) and decoded_key[next_index] == ":":
401+
try:
402+
has_jwk_member = json.loads(
403+
decoded_key[index : end + 1]
404+
) == "kty"
405+
except ValueError:
406+
pass
407+
if has_jwk_member:
408+
break
409+
index = end + 1
410+
if stripped_key.startswith("{") or (
411+
stripped_key.startswith("[") and has_jwk_member
412+
):
379413
raise InvalidKeyError(
380414
"The specified key looks like a JWK and should not be "
381415
"used directly as an HMAC secret. Load it via "
@@ -384,7 +418,18 @@ def prepare_key(self, key: str | bytes) -> bytes:
384418
jwk_obj = None
385419
except ValueError:
386420
jwk_obj = None
387-
if isinstance(jwk_obj, dict) and "kty" in jwk_obj:
421+
contains_jwk_member = False
422+
objects_to_check = [jwk_obj]
423+
while objects_to_check:
424+
obj = objects_to_check.pop()
425+
if isinstance(obj, dict):
426+
if "kty" in obj:
427+
contains_jwk_member = True
428+
break
429+
objects_to_check.extend(obj.values())
430+
elif isinstance(obj, list):
431+
objects_to_check.extend(obj)
432+
if contains_jwk_member:
388433
raise InvalidKeyError(
389434
"The specified key looks like a JWK and should not be "
390435
"used directly as an HMAC secret. Load it via "

tests/test_algorithms.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,68 @@ def test_hmac_prepare_key_rejects_jwk_json(self, jwk_file: str) -> None:
200200
with pytest.raises(InvalidKeyError, match="looks like a JWK"):
201201
algo.prepare_key(keyfile.read())
202202

203+
@pytest.mark.parametrize(
204+
"container", ("jwks", "array", "nested-array", "bom-jwks")
205+
)
206+
def test_hmac_prepare_key_rejects_public_jwk_containers(
207+
self, container: str
208+
) -> None:
209+
algo = HMACAlgorithm(HMACAlgorithm.SHA256)
210+
211+
with open(key_path("jwk_rsa_pub.json")) as keyfile:
212+
public_jwk = json.load(keyfile)
213+
214+
if container == "jwks":
215+
key: Union[str, bytes] = json.dumps({"keys": [public_jwk]})
216+
elif container == "array":
217+
key = json.dumps([public_jwk])
218+
elif container == "nested-array":
219+
key = json.dumps([[public_jwk]])
220+
else:
221+
key = b"\xef\xbb\xbf" + json.dumps(
222+
{"keys": [public_jwk]}
223+
).encode()
224+
225+
with pytest.raises(InvalidKeyError, match="looks like a JWK"):
226+
algo.prepare_key(key)
227+
228+
def test_hmac_prepare_key_rejects_deep_public_jwk_array(self) -> None:
229+
algo = HMACAlgorithm(HMACAlgorithm.SHA256)
230+
depth = 20000
231+
key = b"[" * depth + b'{"kty":"RSA"}' + b"]" * depth
232+
233+
with pytest.raises(InvalidKeyError, match="looks like a JWK"):
234+
algo.prepare_key(key)
235+
236+
def test_hmac_prepare_key_rejects_deep_public_jwk_array_with_escaped_kty(
237+
self,
238+
) -> None:
239+
algo = HMACAlgorithm(HMACAlgorithm.SHA256)
240+
depth = 20000
241+
key = b"[" * depth + b'{"\\u006bty":"RSA"}' + b"]" * depth
242+
243+
with pytest.raises(InvalidKeyError, match="looks like a JWK"):
244+
algo.prepare_key(key)
245+
246+
def test_hmac_prepare_key_accepts_deep_array_secret_with_kty_string(
247+
self,
248+
) -> None:
249+
algo = HMACAlgorithm(HMACAlgorithm.SHA256)
250+
depth = 20000
251+
key = b'["kty",' + b"[" * depth + b"0" + b"]" * depth + b"]"
252+
253+
assert algo.prepare_key(key) == key
254+
255+
def test_hmac_prepare_key_rejects_jwks_with_oversized_integer(self) -> None:
256+
algo = HMACAlgorithm(HMACAlgorithm.SHA256)
257+
with open(key_path("jwk_rsa_pub.json")) as keyfile:
258+
public_jwk = json.load(keyfile)
259+
key = json.dumps({"keys": [public_jwk], "extra": 0})
260+
key = key.replace('"extra": 0', '"extra": ' + "1" * 5000)
261+
262+
with pytest.raises(InvalidKeyError, match="looks like a JWK"):
263+
algo.prepare_key(key)
264+
203265
@pytest.mark.parametrize(
204266
"encoding",
205267
[

0 commit comments

Comments
 (0)