From c532828aab29980250a56a2a28ed205723e7012c Mon Sep 17 00:00:00 2001 From: reverse Date: Wed, 19 Aug 2026 01:31:22 +0000 Subject: [PATCH] =?UTF-8?q?jwt:=20match=20claim=5Fnum=20only=20against=20a?= =?UTF-8?q?=20top-level=20object=20key=20(depth-aware=20scan),=20not=20any?= =?UTF-8?q?=20substring=20=E2=80=94=20a=20claim=20whose=20string=20value?= =?UTF-8?q?=20contained=20"exp":=20could=20otherwise=20spoof=20the=20exp?= =?UTF-8?q?=20an=20external=20verifier=20reads;=20added=20nested=20+=20str?= =?UTF-8?q?ing-value=20regression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/jwt.rs | 53 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/src/modules/jwt.rs b/src/modules/jwt.rs index 413ff24..51e19b8 100644 --- a/src/modules/jwt.rs +++ b/src/modules/jwt.rs @@ -63,17 +63,48 @@ pub fn verify_hs256(token: &str, secret: &str) -> Option { String::from_utf8(claims).ok() } -/// Read a numeric claim (e.g. `exp`, `iat`) from raw-JSON claims text. +/// Read a numeric claim (e.g. `exp`, `iat`) from raw-JSON claims text. Matches `key` +/// only as a *top-level* object key (depth 1) immediately followed by `:`, so a claim +/// whose string *value* contains `"exp":…` can't spoof what a verifier reads. pub fn claim_num(claims_json: &str, key: &str) -> Option { let needle = format!("\"{key}\""); - let pos = claims_json.find(&needle)?; - let after = &claims_json[pos + needle.len()..]; - let colon = after.find(':')?; - let tail = after[colon + 1..].trim_start(); - let end = tail - .find(|c: char| !c.is_ascii_digit() && c != '-') - .unwrap_or(tail.len()); - tail[..end].parse().ok() + let bytes = claims_json.as_bytes(); + let (mut depth, mut in_str, mut esc, mut i) = (0i32, false, false, 0usize); + while i < bytes.len() { + let b = bytes[i]; + if in_str { + if esc { + esc = false; + } else if b == b'\\' { + esc = true; + } else if b == b'"' { + in_str = false; + } + i += 1; + continue; + } + match b { + b'{' | b'[' => depth += 1, + b'}' | b']' => depth -= 1, + b'"' => { + // a real top-level key is `"key"` at depth 1 followed by `:` + if depth == 1 && claims_json[i..].starts_with(&needle) { + let rest = claims_json[i + needle.len()..].trim_start(); + if let Some(tail) = rest.strip_prefix(':') { + let tail = tail.trim_start(); + let end = tail + .find(|c: char| !c.is_ascii_digit() && c != '-') + .unwrap_or(tail.len()); + return tail[..end].parse().ok(); + } + } + in_str = true; + } + _ => {} + } + i += 1; + } + None } #[cfg(test)] @@ -105,5 +136,9 @@ mod tests { assert_eq!(claim_num(c, "exp"), Some(1730000000)); assert_eq!(claim_num(c, "iat"), Some(1729998200)); assert_eq!(claim_num(c, "nope"), None); + // a nested object's key must not be read as the top-level claim + assert_eq!(claim_num(r#"{"data":{"exp":999},"exp":42}"#, "exp"), Some(42)); + // a string value containing `"exp":` must not spoof it + assert_eq!(claim_num("{\"note\":\"\\\"exp\\\":13\",\"exp\":7}", "exp"), Some(7)); } }