Skip to main content

security_rust/data/
jwt_attack.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7/// 各分支合成一条 alternation。前四条是 `(?i)`,后两条**故意不是**——
8/// JWT 的 `ey…` 前缀是大小写敏感的 base64url,故 flags 逐条包裹,不能提到最前面。
9static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
10    Regex::new(
11        &[
12            r#"(?i:"alg"\s*:\s*"none")"#,
13            r#"|(?i:"kid"\s*:.*\.\.\/)"#,
14            r#"|(?i:"kid"\s*:.*\.\.\\)"#,
15            r#"|(?i:"kid"\s*:.*/dev/null)"#,
16            r"|ey[A-Za-z0-9_-]+\.ey[A-Za-z0-9_-]+\.[\s]*",
17            r"|ey[A-Za-z0-9_-]+\.[\s]*\.[A-Za-z0-9_-]+",
18        ]
19        .concat(),
20    )
21    .unwrap()
22});
23
24pub struct JwtAttackDetector;
25
26impl Detector for JwtAttackDetector {
27    fn name(&self) -> &'static str {
28        "jwt_attack"
29    }
30
31    fn detect(&self, input: &str) -> Option<DetectionResult> {
32        regex_detect(
33            std::slice::from_ref(&*PATTERNS),
34            self.name(),
35            AttackCategory::Data,
36            Severity::High,
37            "JWT attack detected",
38            input,
39        )
40    }
41}
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46
47    #[test]
48    fn name_returns_attack_type() {
49        assert_eq!(JwtAttackDetector.name(), "jwt_attack");
50    }
51
52    #[test]
53    fn detects_none_algorithm() {
54        for payload in [
55            r#"{"alg": "none", "typ": "JWT"}"#,
56            r#"{"alg":"None"}"#,
57            r#"{"alg": "NONE"}"#,
58            r#"{"alg": "noNe"}"#,
59        ] {
60            let r = JwtAttackDetector
61                .detect(payload)
62                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
63            assert_eq!(r.attack_type, "jwt_attack");
64            assert_eq!(r.category, AttackCategory::Data);
65            assert_eq!(r.severity, Severity::High);
66            assert!(
67                !r.matched_pattern.is_empty(),
68                "matched_pattern empty for {:?}",
69                payload
70            );
71            assert!(
72                r.offset <= payload.len(),
73                "offset out of range for {:?}",
74                payload
75            );
76        }
77    }
78
79    #[test]
80    fn detects_kid_traversal() {
81        for payload in [
82            r#"{"kid": "../../etc/passwd"}"#,
83            r#"{"kid": "/dev/null"}"#,
84            r#"{"kid": "..\\..\\key"}"#,
85        ] {
86            let r = JwtAttackDetector
87                .detect(payload)
88                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
89            assert!(
90                !r.matched_pattern.is_empty(),
91                "matched_pattern empty for {:?}",
92                payload
93            );
94            assert!(
95                r.offset <= payload.len(),
96                "offset out of range for {:?}",
97                payload
98            );
99        }
100    }
101
102    #[test]
103    fn detects_token_shape() {
104        for payload in [
105            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.abc123",
106            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.",
107            "eyJhbGciOiJIUzI1NiJ9. .abc123",
108        ] {
109            let r = JwtAttackDetector
110                .detect(payload)
111                .unwrap_or_else(|| panic!("expected detection for {:?}", payload));
112            assert!(
113                !r.matched_pattern.is_empty(),
114                "matched_pattern empty for {:?}",
115                payload
116            );
117            assert!(
118                r.offset <= payload.len(),
119                "offset out of range for {:?}",
120                payload
121            );
122        }
123    }
124
125    #[test]
126    fn ignores_benign_inputs() {
127        for input in [
128            "Hello, this is a normal text input.",
129            r#"{"alg": "HS256"}"#,
130            r"{'alg': 'none'}",
131            r#"{"kid": "key-1"}"#,
132            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ",
133            "The eye color is blue",
134        ] {
135            assert!(
136                JwtAttackDetector.detect(input).is_none(),
137                "false positive: {:?}",
138                input
139            );
140        }
141    }
142
143    #[test]
144    fn edge_cases() {
145        assert!(JwtAttackDetector.detect("").is_none());
146        assert!(JwtAttackDetector.detect("   ").is_none());
147        assert!(JwtAttackDetector.detect("алгоритм: none").is_none());
148    }
149}