From 1c361e88f317d3fb524cf20aac10ab334e4df045 Mon Sep 17 00:00:00 2001 From: VoidChecksum Date: Sat, 30 May 2026 12:59:32 +0200 Subject: [PATCH 1/2] fix(tools/web): tolerate non-string JWT header fields in parse_token Bug: JWTHeader.from_dict stores header fields verbatim from JSON, so numeric/list alg/kid/jku values cause AttributeError/TypeError in the findings block of parse_token (e.g. int.lower() crashes on alg=1). Fix: coerce alg_s/kid_s/jku_s to str before all .lower()/"in"/ .startswith() checks, preserving None-as-empty semantics for kid/jku. Regression test: test_jwt_nonstring_headers.py covers int alg, list alg, int kid, int jku, plus verifies string-path findings still fire. --- .../decepticon/decepticon/tools/web/jwt.py | 21 ++++++-- .../unit/web/test_jwt_nonstring_headers.py | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 packages/decepticon/tests/unit/web/test_jwt_nonstring_headers.py diff --git a/packages/decepticon/decepticon/tools/web/jwt.py b/packages/decepticon/decepticon/tools/web/jwt.py index 9b13967a..0b904137 100644 --- a/packages/decepticon/decepticon/tools/web/jwt.py +++ b/packages/decepticon/decepticon/tools/web/jwt.py @@ -180,14 +180,25 @@ def parse_token(token: str) -> JWTToken: claims = JWTClaims.from_dict(claim_data) tok = JWTToken(header=header, claims=claims, signature=sig, raw=token, findings=findings) - # High-value header findings - if header.alg.lower() == "none": + alg_s = header.alg if isinstance(header.alg, str) else str(header.alg) + kid_s = ( + header.kid + if isinstance(header.kid, str) + else ("" if header.kid is None else str(header.kid)) + ) + jku_s = ( + header.jku + if isinstance(header.jku, str) + else ("" if header.jku is None else str(header.jku)) + ) + + if alg_s.lower() == "none": tok.findings.append("alg=none — server MAY accept unsigned tokens (CVE class)") - if header.alg.lower() == "hs256" and header.jku: + if alg_s.lower() == "hs256" and jku_s: tok.findings.append("alg=HS256 with jku header — key confusion candidate") - if header.kid and ("../" in header.kid or "%2f" in header.kid.lower()): + if kid_s and ("../" in kid_s or "%2f" in kid_s.lower()): tok.findings.append("kid contains path traversal — file read / SQLi candidate") - if header.jku and not header.jku.startswith("https://"): + if jku_s and not jku_s.startswith("https://"): tok.findings.append("jku over non-HTTPS or attacker-controlled host — key confusion") if claims.expired: tok.findings.append("token already expired — test whether server enforces exp") diff --git a/packages/decepticon/tests/unit/web/test_jwt_nonstring_headers.py b/packages/decepticon/tests/unit/web/test_jwt_nonstring_headers.py new file mode 100644 index 00000000..dc14eedf --- /dev/null +++ b/packages/decepticon/tests/unit/web/test_jwt_nonstring_headers.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import base64 +import json + +from decepticon.tools.web.jwt import parse_token + + +def _make_token(header: dict, claims: dict | None = None) -> str: + def _b64url(d: dict) -> str: + raw = json.dumps(d, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + h = _b64url(header) + b = _b64url(claims or {"sub": "x"}) + return f"{h}.{b}." + + +class TestNonStringHeaderFields: + def test_numeric_alg_does_not_raise(self) -> None: + token = _make_token({"alg": 1, "typ": "JWT"}) + result = parse_token(token) + assert result is not None + + def test_numeric_alg_none_variant_flagged(self) -> None: + token = _make_token({"alg": "none", "typ": "JWT"}) + result = parse_token(token) + assert any("alg=none" in f for f in result.findings) + + def test_list_alg_does_not_raise(self) -> None: + token = _make_token({"alg": ["none"], "typ": "JWT"}) + result = parse_token(token) + assert result is not None + + def test_numeric_kid_does_not_raise(self) -> None: + token = _make_token({"alg": "HS256", "kid": 1234, "typ": "JWT"}) + result = parse_token(token) + assert result is not None + + def test_numeric_jku_does_not_raise(self) -> None: + token = _make_token({"alg": "HS256", "jku": 999, "typ": "JWT"}) + result = parse_token(token) + assert result is not None + + def test_string_kid_traversal_still_flagged(self) -> None: + token = _make_token({"alg": "HS256", "kid": "../../../etc/passwd", "typ": "JWT"}) + result = parse_token(token) + assert any("path traversal" in f for f in result.findings) + + def test_string_jku_non_https_still_flagged(self) -> None: + token = _make_token({"alg": "HS256", "jku": "http://evil.com/jwks", "typ": "JWT"}) + result = parse_token(token) + assert any("jku" in f for f in result.findings) From c5d732f786908a711a1109c1d124fbf47c0d0462 Mon Sep 17 00:00:00 2001 From: VoidChecksum Date: Sat, 30 May 2026 15:09:59 +0200 Subject: [PATCH 2/2] feat(tools/web): flag any jku and add x5u key-injection detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds the unique JWT enhancement from #414 into this PR: flag ANY jku header (not only non-HTTPS — an attacker-influenced https JWKS URL is still a key-confusion vector) and add the previously-missing x5u (X.509 URL) check. Uses the string-safe coercion from this PR's non-string-header fix so non-string jku/x5u values still can't crash parse_token. Adds regression tests. Consolidates #414's jwt change; #414's http_request part is already covered by #358. --- .../decepticon/decepticon/tools/web/jwt.py | 11 ++++++-- .../unit/web/test_jwt_jku_x5u_flagging.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 packages/decepticon/tests/unit/web/test_jwt_jku_x5u_flagging.py diff --git a/packages/decepticon/decepticon/tools/web/jwt.py b/packages/decepticon/decepticon/tools/web/jwt.py index 0b904137..f67bedbc 100644 --- a/packages/decepticon/decepticon/tools/web/jwt.py +++ b/packages/decepticon/decepticon/tools/web/jwt.py @@ -191,6 +191,11 @@ def parse_token(token: str) -> JWTToken: if isinstance(header.jku, str) else ("" if header.jku is None else str(header.jku)) ) + x5u_s = ( + header.x5u + if isinstance(header.x5u, str) + else ("" if header.x5u is None else str(header.x5u)) + ) if alg_s.lower() == "none": tok.findings.append("alg=none — server MAY accept unsigned tokens (CVE class)") @@ -198,8 +203,10 @@ def parse_token(token: str) -> JWTToken: tok.findings.append("alg=HS256 with jku header — key confusion candidate") if kid_s and ("../" in kid_s or "%2f" in kid_s.lower()): tok.findings.append("kid contains path traversal — file read / SQLi candidate") - if jku_s and not jku_s.startswith("https://"): - tok.findings.append("jku over non-HTTPS or attacker-controlled host — key confusion") + if jku_s: + tok.findings.append("jku points at an attacker-influenced host — key confusion") + if x5u_s: + tok.findings.append("x5u points at an attacker-influenced host — key confusion") if claims.expired: tok.findings.append("token already expired — test whether server enforces exp") if claims.exp is None: diff --git a/packages/decepticon/tests/unit/web/test_jwt_jku_x5u_flagging.py b/packages/decepticon/tests/unit/web/test_jwt_jku_x5u_flagging.py new file mode 100644 index 00000000..a1bf9c9a --- /dev/null +++ b/packages/decepticon/tests/unit/web/test_jwt_jku_x5u_flagging.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from decepticon.tools.web.jwt import forge_token, parse_token + + +def test_https_jku_is_flagged_not_just_non_https(): + t = forge_token( + {"sub": "a"}, + alg="HS256", + secret="k", + header={"jku": "https://attacker.example/jwks"}, + ) + parsed = parse_token(t) + assert any("jku" in f for f in parsed.findings) + + +def test_x5u_header_is_flagged(): + t = forge_token( + {"sub": "b"}, + alg="HS256", + secret="k", + header={"x5u": "https://attacker.example/cert.pem"}, + ) + parsed = parse_token(t) + assert any("x5u" in f for f in parsed.findings)