Skip to main content

security_rust/protocol/
cors.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity};
7
8// `Access-Control-Allow-Origin: *` 单独出现是公开 API / 静态资源 / CDN 的常态
9// (而且浏览器本来就拒绝它带凭据),静态白名单更是正常配置——都不报。
10// 真危险的只有两种可判定的形态:
11//   1. 显式 `null` 源——沙箱 iframe、`data:` URL 的源就是 null,服务端一旦回显,
12//      它们就能读到带凭据的响应;
13//   2. `*` 与 `Credentials: true` 同现——坏配置,浏览器会拒绝,但配置本身是信号。
14// 反射型(ACAO 回显请求的 Origin)**故意不判**:白名单命中时回显正是合法实现,
15// 只看两个值相等无法把正常动态 CORS 和攻击区分开,判了必然大面积误杀。
16static 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());
22static ORIGIN_NULL: LazyLock<Regex> =
23    LazyLock::new(|| Regex::new(r"(?i)Origin:\s*null\b").unwrap());
24
25pub struct CorsDetector;
26
27impl Detector for CorsDetector {
28    fn name(&self) -> &'static str {
29        "cors"
30    }
31
32    fn detect(&self, input: &str) -> Option<DetectionResult> {
33        // 顺序有讲究:`Access-Control-Allow-Origin: null` 里含有 `Origin: null` 子串,
34        // 先判 ACAO 形态,命中时 matched_pattern 才是完整的那一行。
35        let m = if let Some(m) = ACAO_NULL.find(input) {
36            m
37        } else if let Some(m) = ACAO_WILDCARD
38            .find(input)
39            .filter(|_| CREDS_TRUE.is_match(input))
40        {
41            m
42        } else {
43            ORIGIN_NULL.find(input)?
44        };
45        Some(DetectionResult {
46            attack_type: self.name().to_string(),
47            category: AttackCategory::Protocol,
48            severity: Severity::Medium,
49            matched_pattern: m.as_str().to_string(),
50            offset: m.start(),
51            message: "CORS bypass attempt detected".into(),
52        })
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn assert_detected(input: &str) {
61        crate::test_helpers::assert_detected(
62            &CorsDetector,
63            input,
64            AttackCategory::Protocol,
65            Severity::Medium,
66        );
67    }
68
69    fn assert_clean(input: &str) {
70        crate::test_helpers::assert_clean(&CorsDetector, input);
71    }
72
73    #[test]
74    fn name_is_cors() {
75        assert_eq!(CorsDetector.name(), "cors");
76    }
77
78    #[test]
79    fn detects_null_origin() {
80        assert_detected("Origin: null");
81        assert_detected("Origin:null");
82    }
83
84    #[test]
85    fn detects_null_allow_origin() {
86        assert_detected("Access-Control-Allow-Origin: null");
87        assert_detected("Access-Control-Allow-Origin:null");
88        // `Origin: null` 是上面的 ACAO 行的子串,匹配到完整那一行而不是片段
89        let r = CorsDetector
90            .detect("Access-Control-Allow-Origin: null")
91            .unwrap();
92        assert_eq!(r.matched_pattern, "Access-Control-Allow-Origin: null");
93    }
94
95    #[test]
96    fn wildcard_alone_is_clean_but_with_credentials_is_not() {
97        // 公开 API / 静态资源常态,浏览器也拒绝它带凭据 —— 单独不报
98        assert_clean("Access-Control-Allow-Origin: *");
99        assert_clean("Access-Control-Allow-Origin:*");
100        // `*` + 凭据同现是坏配置,报
101        assert_detected("Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Credentials: true");
102        assert_detected("Access-Control-Allow-Origin:*\nAccess-Control-Allow-Credentials:true");
103    }
104
105    #[test]
106    fn allow_credentials_alone_is_clean() {
107        // 与具体白名单源搭配时是正常响应头,单独出现不构成信号
108        assert_clean("Access-Control-Allow-Credentials: true");
109    }
110
111    #[test]
112    fn detects_mixed_case() {
113        assert_detected("origin: NULL");
114        assert_detected("access-control-allow-origin: null");
115        assert_detected("access-control-allow-origin: *\r\naccess-control-allow-credentials: TRUE");
116    }
117
118    #[test]
119    fn rejects_benign_headers() {
120        assert_clean("Origin: http://example.com");
121        assert_clean("Access-Control-Allow-Origin: https://example.com");
122        assert_clean("Access-Control-Allow-Origin: *");
123        assert_clean("Access-Control-Allow-Credentials: false");
124        assert_clean("Origin: *");
125        // 反射/白名单回显:与请求 Origin 相同的 ACAO 是正常动态 CORS
126        assert_clean(
127            "Origin: https://example.com\r\nAccess-Control-Allow-Origin: https://example.com",
128        );
129        // `null` 必须是完整的 token
130        assert_clean("Access-Control-Allow-Origin: nullify-me");
131    }
132
133    #[test]
134    fn rejects_space_before_colon() {
135        assert_clean("Access-Control-Allow-Origin : *");
136    }
137
138    #[test]
139    fn rejects_empty_and_whitespace() {
140        assert_clean("");
141        assert_clean("   ");
142    }
143
144    #[test]
145    fn rejects_unicode_text() {
146        assert_clean("跨域配置说明,无攻击");
147    }
148}