security_rust/data/
jwt_attack.rs1use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
4use regex::Regex;
5use std::sync::LazyLock;
6
7static 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}