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());
22
23// 请求侧的 `Origin: null` 是弱信号:沙箱 iframe、`data:` URL、本地文件的源就是
24// `null`,它们发正常跨域请求时带的就是这个头——"出现"本身不构成攻击。要成立得再看
25// 服务端是否用 `ACAO: null` 回显,那一条(ACAO_NULL)才是可判定的形态。所以这里
26// 报 Low:仍然命中,但不单独触发拒绝,也不参与"多条 Medium 叠加成 High"。
27static 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        // 顺序有讲究:`Access-Control-Allow-Origin: null` 里含有 `Origin: null` 子串,
39        // 先判 ACAO 形态,命中时 matched_pattern 才是完整的那一行。
40        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        // 弱档:请求侧的 `Origin: null` 仍然命中,但不单独越线
90        assert_hit_at("Origin: null", Severity::Low);
91        assert_hit_at("Origin:null", Severity::Low);
92    }
93
94    /// 沙箱 iframe / `data:` URL / 本地文件的源就是 `null`,它们发正常跨域请求时
95    /// 带的就是这个头。判 Medium 时这类正常请求会参与"多条 Medium 叠加成 High",
96    /// 所以收在弱档:命中(不静默漏报)但单条 5 分。
97    #[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        // 服务端回显 `null` 才是可判定的坏配置,仍是 Medium
104        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        // `Origin: null` 是上面的 ACAO 行的子串,匹配到完整那一行而不是片段
114        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        // 公开 API / 静态资源常态,浏览器也拒绝它带凭据 —— 单独不报
123        assert_clean("Access-Control-Allow-Origin: *");
124        assert_clean("Access-Control-Allow-Origin:*");
125        // `*` + 凭据同现是坏配置,报
126        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        // 与具体白名单源搭配时是正常响应头,单独出现不构成信号
133        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        // 反射/白名单回显:与请求 Origin 相同的 ACAO 是正常动态 CORS
151        assert_clean(
152            "Origin: https://example.com\r\nAccess-Control-Allow-Origin: https://example.com",
153        );
154        // `null` 必须是完整的 token
155        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}