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
30 changes: 24 additions & 6 deletions packages/decepticon/decepticon/tools/web/jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions packages/decepticon/tests/unit/web/test_jwt_jku_x5u_flagging.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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)
Loading