security_rust/protocol/
cors.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity};
7
8static ACAO_NULL: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"(?i)Access-Control-Allow-Origin:\s*null\b").unwrap());
18static ACAO_WILDCARD: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"(?i)Access-Control-Allow-Origin:\s*\*").unwrap());
20static CREDS_TRUE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"(?i)Access-Control-Allow-Credentials:\s*true").unwrap());
22
23static ORIGIN_NULL: LazyLock<Regex> =
28 LazyLock::new(|| Regex::new(r"(?i)Origin:\s*null\b").unwrap());
29
30pub struct CorsDetector;
31
32impl Detector for CorsDetector {
33 fn name(&self) -> &'static str {
34 "cors"
35 }
36
37 fn detect(&self, input: &str) -> Option<DetectionResult> {
38 let (m, severity) = if let Some(m) = ACAO_NULL.find(input) {
41 (m, Severity::Medium)
42 } else if let Some(m) = ACAO_WILDCARD
43 .find(input)
44 .filter(|_| CREDS_TRUE.is_match(input))
45 {
46 (m, Severity::Medium)
47 } else {
48 (ORIGIN_NULL.find(input)?, Severity::Low)
49 };
50 Some(DetectionResult {
51 attack_type: self.name().to_string(),
52 category: AttackCategory::Protocol,
53 severity,
54 matched_pattern: m.as_str().to_string(),
55 offset: m.start(),
56 message: "CORS bypass attempt detected".into(),
57 })
58 }
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 fn assert_detected(input: &str) {
66 assert_hit_at(input, Severity::Medium);
67 }
68
69 fn assert_hit_at(input: &str, severity: Severity) {
70 crate::test_helpers::assert_detected(
71 &CorsDetector,
72 input,
73 AttackCategory::Protocol,
74 severity,
75 );
76 }
77
78 fn assert_clean(input: &str) {
79 crate::test_helpers::assert_clean(&CorsDetector, input);
80 }
81
82 #[test]
83 fn name_is_cors() {
84 assert_eq!(CorsDetector.name(), "cors");
85 }
86
87 #[test]
88 fn detects_null_origin() {
89 assert_hit_at("Origin: null", Severity::Low);
91 assert_hit_at("Origin:null", Severity::Low);
92 }
93
94 #[test]
98 fn sandboxed_origin_null_hits_only_the_weak_tier() {
99 assert_hit_at(
100 "Origin: null\r\nSec-Fetch-Mode: cors\r\nSec-Fetch-Site: cross-site\r\n\r\n",
101 Severity::Low,
102 );
103 assert_detected(
105 "Access-Control-Allow-Origin: null\r\nAccess-Control-Allow-Credentials: true",
106 );
107 }
108
109 #[test]
110 fn detects_null_allow_origin() {
111 assert_detected("Access-Control-Allow-Origin: null");
112 assert_detected("Access-Control-Allow-Origin:null");
113 let r = CorsDetector
115 .detect("Access-Control-Allow-Origin: null")
116 .unwrap();
117 assert_eq!(r.matched_pattern, "Access-Control-Allow-Origin: null");
118 }
119
120 #[test]
121 fn wildcard_alone_is_clean_but_with_credentials_is_not() {
122 assert_clean("Access-Control-Allow-Origin: *");
124 assert_clean("Access-Control-Allow-Origin:*");
125 assert_detected("Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Credentials: true");
127 assert_detected("Access-Control-Allow-Origin:*\nAccess-Control-Allow-Credentials:true");
128 }
129
130 #[test]
131 fn allow_credentials_alone_is_clean() {
132 assert_clean("Access-Control-Allow-Credentials: true");
134 }
135
136 #[test]
137 fn detects_mixed_case() {
138 assert_hit_at("origin: NULL", Severity::Low);
139 assert_detected("access-control-allow-origin: null");
140 assert_detected("access-control-allow-origin: *\r\naccess-control-allow-credentials: TRUE");
141 }
142
143 #[test]
144 fn rejects_benign_headers() {
145 assert_clean("Origin: http://example.com");
146 assert_clean("Access-Control-Allow-Origin: https://example.com");
147 assert_clean("Access-Control-Allow-Origin: *");
148 assert_clean("Access-Control-Allow-Credentials: false");
149 assert_clean("Origin: *");
150 assert_clean(
152 "Origin: https://example.com\r\nAccess-Control-Allow-Origin: https://example.com",
153 );
154 assert_clean("Access-Control-Allow-Origin: nullify-me");
156 }
157
158 #[test]
159 fn rejects_space_before_colon() {
160 assert_clean("Access-Control-Allow-Origin : *");
161 }
162
163 #[test]
164 fn rejects_empty_and_whitespace() {
165 assert_clean("");
166 assert_clean(" ");
167 }
168
169 #[test]
170 fn rejects_unicode_text() {
171 assert_clean("跨域配置说明,无攻击");
172 }
173}