diff --git a/packages/decepticon/decepticon/tools/web/jwt.py b/packages/decepticon/decepticon/tools/web/jwt.py index 9b13967a..f67bedbc 100644 --- a/packages/decepticon/decepticon/tools/web/jwt.py +++ b/packages/decepticon/decepticon/tools/web/jwt.py @@ -180,15 +180,33 @@ 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)) + ) + 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)") - 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://"): - 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) 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)