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