security_rust/protocol/
header_injection.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
13 Regex::new(
14 &[
15 r"(?i)\r\n\s*(?:Set-Cookie|Location|Refresh|Status|WWW-Authenticate):",
27 r"|%0[dD].*%0[aA]",
28 r"|%0[aA].*%0[dD]",
33 ]
34 .concat(),
35 )
36 .unwrap()
37});
38
39pub struct HeaderInjectionDetector;
40
41impl Detector for HeaderInjectionDetector {
42 fn name(&self) -> &'static str {
43 "header_injection"
44 }
45
46 fn detect(&self, input: &str) -> Option<DetectionResult> {
47 regex_detect(
48 std::slice::from_ref(&*PATTERNS),
49 self.name(),
50 AttackCategory::Protocol,
51 Severity::High,
52 "HTTP header injection (CRLF) detected",
53 input,
54 )
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 fn assert_detected(input: &str) {
63 crate::test_helpers::assert_detected(
64 &HeaderInjectionDetector,
65 input,
66 AttackCategory::Protocol,
67 Severity::High,
68 );
69 }
70
71 fn assert_clean(input: &str) {
72 crate::test_helpers::assert_clean(&HeaderInjectionDetector, input);
73 }
74
75 #[test]
76 fn name_is_header_injection() {
77 assert_eq!(HeaderInjectionDetector.name(), "header_injection");
78 }
79
80 #[test]
81 fn detects_encoded_crlf_set_cookie() {
82 assert_detected("test%0d%0aSet-Cookie: evil=true");
83 }
84
85 #[test]
86 fn detects_encoded_crlf_location() {
87 assert_detected("redirect?url=%0D%0ALocation: /admin");
88 }
89
90 #[test]
91 fn detects_encoded_crlf_content_length() {
92 assert_detected("body%0d%0aContent-Length: 0");
93 }
94
95 #[test]
96 fn detects_raw_crlf_response_headers() {
97 assert_detected("foo\r\nSet-Cookie: evil=true");
100 assert_detected("bar\r\nLocation: http://evil.com");
101 assert_detected("foo\r\n Set-Cookie: evil=true");
102 }
103
104 #[test]
105 fn detects_scattered_encoded_crlf() {
106 assert_detected("a%0dcontent%0a");
107 }
108
109 #[test]
110 fn detects_reverse_order_encoded_crlf() {
111 assert_detected("%0a%0d");
114 assert_detected("value%0a%0dSet-Cookie: session=evil");
115 assert_detected("a%0Acontent%0D");
116 }
117
118 #[test]
119 fn detects_injection_via_redirect_headers() {
120 assert_detected("value\r\nRefresh: 0;url=http://evil.com");
123 assert_detected("value\r\nStatus: 302");
124 assert_detected("value\r\nWWW-Authenticate: Basic realm=x");
125 }
126
127 #[test]
128 fn rejects_lf_only_prose() {
129 assert_clean("Meeting at 3pm\nlocation: Room 5");
132 assert_clean("笔记\nset-cookie: abc");
133 assert_clean("配置:\nrefresh: 30\nlocation: /var/www");
134 assert_clean("note\ncontent-length: 0");
135 }
136
137 #[test]
138 fn rejects_clean_headers() {
139 assert_clean("Set-Cookie: evil=true");
140 assert_clean("Location: /index.php");
141 assert_clean("Content-Type: text/html");
142 }
143
144 #[test]
145 fn rejects_lone_encoded_chars() {
146 assert_clean("%0d");
147 assert_clean("%0a");
148 assert_clean("test%0dend");
149 assert_clean("%0d%0d");
150 }
151
152 #[test]
156 fn rejects_well_formed_requests() {
157 for req in [
158 "GET /index.html HTTP/1.1\r\nHost: example.com\r\nUser-Agent: curl/8.5.0\r\nAccept: */*\r\n",
159 "POST /upload HTTP/1.1\r\nHost: upload.example.com\r\nContent-Type: multipart/form-data; boundary=----x\r\nContent-Length: 1234\r\nTransfer-Encoding: chunked\r\n\r\n",
160 "foo\r\nContent-Type: text/html",
162 "bar\r\nTransfer-Encoding: chunked",
163 ] {
164 assert_clean(req);
165 }
166 assert_clean("The report was generated with SYSTEM \"production\" settings.");
167 assert_clean("Please cc: ops@example.com on the reply.");
168 assert_clean("/search?tag=rust&tag=security&page=2");
169 }
170
171 #[test]
172 fn rejects_lf_only_newlines() {
173 assert_clean("foo\nContent-Type: text/html");
174 assert_clean("foo\nLocation: /x");
175 }
176
177 #[test]
178 fn rejects_near_misses() {
179 assert_clean("foo\r\nContent-Type text/html");
180 }
181
182 #[test]
183 fn rejects_empty_and_whitespace() {
184 assert_clean("");
185 assert_clean(" ");
186 assert_clean("\r\n");
187 }
188
189 #[test]
190 fn rejects_unicode_text() {
191 assert_clean("这是一段正常文本,无注入");
192 }
193}