Skip to main content

security_rust/protocol/
open_redirect.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// 强档:伪协议 URI。形态约束是「scheme 后面**跟着内容**」——
9// `javascript:` 后面必须是实参(`javascript:alert(1)`),`data:text/html` 后面必须
10// 是 `,` 或 `;` 分段(没有数据段的 data URI 根本不是合法 URI)。散文里的
11// `The javascript: URL scheme is blocked by the CSP.` / `the data:text/html payload`
12// 只是术语,不报。
13// 三条同档模式合成一条 alternation:一次 `find_iter` 扫完三条分支,而不是逐条
14// `find_iter` 各扫一遍。`(?i)` 为三条分支所共有,提到最前——它的作用域是整条模式
15// (含 `|` 之后的全部分支),与逐条编译时每条各自带 `(?i)` 等价。
16// 下面 `detect()` 里 `://` 前缀的跳过逻辑不变:它按「匹配起点前一个字节是不是 `:`」判,
17// 与模式怎么分组无关。
18static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
19    Regex::new(
20        &[
21            r"(?i)javascript\s*:[^\s]",
22            r"|data\s*:\s*text/html\s*[,;]",
23            r"|data\s*:\s*text/plain\s*[,;]",
24        ]
25        .concat(),
26    )
27    .unwrap()
28});
29
30// 弱档:`//host.tld`(协议相对 URL)。攻击形态 `?next=//evil.com` 与正常内容里
31// 出现的协议相对 URL 逐字节同形——源码注释 `//github.com/owner/repo`、文档
32// `Use //cdn.example.com/x.js`——正则分不开,所以只报 Low:仍然命中,但不单独
33// 越线,也不参与"多条 Medium 叠加成 High"。
34// `://` 前缀的跳过见 detect():`http://www.w3.org` 里的 `//` 是 scheme 的一部分。
35//
36// 本模式的天花板就在这里,不要试图再收紧:token 起始处的协议相对 URL(上面那两个
37// 例子)**仍会命中 Low**——它们与攻击逐字节相同,只有值(跳去哪个主机)能区分,
38// 而值在正常内容里同样合法。能收紧的只有"嵌在更长 URL 里"的那一半,已由上面的
39// `://` 跳过覆盖。
40static WEAK_PATTERNS: LazyLock<Regex> =
41    LazyLock::new(|| Regex::new(r"(?i)//[^/\s]+\.[a-z]{2,}").unwrap());
42
43pub struct OpenRedirectDetector;
44
45impl Detector for OpenRedirectDetector {
46    fn name(&self) -> &'static str {
47        "open_redirect"
48    }
49
50    fn detect(&self, input: &str) -> Option<DetectionResult> {
51        for (re, severity) in [
52            (&*STRONG_PATTERNS, Severity::Medium),
53            (&*WEAK_PATTERNS, Severity::Low),
54        ] {
55            for m in re.find_iter(input) {
56                let start = m.start();
57                // `//` 紧跟在 `:` 后面时属于更长的 URL,不是重定向目标:
58                // `http://www.w3.org/2000/svg` 的 `//` 是 scheme 的一部分。
59                if start > 0 && input.as_bytes()[start - 1] == b':' {
60                    continue;
61                }
62                return Some(DetectionResult {
63                    attack_type: "open_redirect".into(),
64                    category: AttackCategory::Protocol,
65                    severity,
66                    matched_pattern: m.as_str().to_string(),
67                    offset: start,
68                    message: "Open redirect detected".into(),
69                });
70            }
71        }
72        None
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    fn assert_detected(input: &str) {
81        assert_hit_at(input, Severity::Medium);
82    }
83
84    fn assert_hit_at(input: &str, severity: Severity) {
85        crate::test_helpers::assert_detected(
86            &OpenRedirectDetector,
87            input,
88            AttackCategory::Protocol,
89            severity,
90        );
91    }
92
93    fn assert_clean(input: &str) {
94        crate::test_helpers::assert_clean(&OpenRedirectDetector, input);
95    }
96
97    #[test]
98    fn name_is_open_redirect() {
99        assert_eq!(OpenRedirectDetector.name(), "open_redirect");
100    }
101
102    /// 弱档:协议相对 URL 仍然命中,只是不再单独越线(见下面的注释/文档样例)。
103    #[test]
104    fn detects_double_slash_url() {
105        assert_hit_at("//evil.com/phishing", Severity::Low);
106        assert_hit_at("//attacker.org/steal?u=1", Severity::Low);
107        assert_hit_at("https://ok.com//evil.com", Severity::Low); // scheme match skipped, later redirect still caught
108    }
109
110    #[test]
111    fn detects_javascript_uri() {
112        assert_detected("javascript:alert(document.cookie)");
113    }
114
115    #[test]
116    fn detects_data_html_uri() {
117        assert_detected("data:text/html,<script>alert(1)</script>");
118    }
119
120    #[test]
121    fn detects_data_plain_uri() {
122        assert_detected("data: text/plain;base64,SGVsbG8=");
123    }
124
125    #[test]
126    fn detects_mixed_case() {
127        assert_detected("JAVASCRIPT:alert(1)");
128        assert_hit_at("//Evil.Com/", Severity::Low);
129    }
130
131    /// 术语 vs 用法:散文里裸的 scheme 名(后面跟空格/标点)不是 URI 用法;
132    /// `data:` 光有 MIME 而没有数据分段也不构成合法 URI。旧模式
133    /// (`javascript\s*:` / `data\s*:\s*text/html`)把这三条都判 Medium。
134    #[test]
135    fn prose_mentioning_schemes_is_clean() {
136        assert_clean("The javascript: URL scheme is blocked by the CSP in modern browsers.");
137        assert_clean("Browsers render the data:text/html payload as a document.");
138        assert_clean("Prefer data:text/plain for the log preview.");
139    }
140
141    /// 协议相对 URL 在正常内容里的样子:源码注释、文档里的 CDN 链接。
142    /// 它们与攻击同形,所以是弱档命中(5 分)而不是干净。
143    #[test]
144    fn protocol_relative_urls_in_docs_hit_only_the_weak_tier() {
145        for input in [
146            "//github.com/rust-lang/rust/issues/1\nfn main() {}\n",
147            "Use //cdn.example.com/x.js only when the CDN has TLS available.\n",
148        ] {
149            assert_hit_at(input, Severity::Low);
150        }
151        // 文档里的绝对 URL 不走弱档:`//` 前面是 `:`,属于 scheme
152        assert_clean("See https://example.com/docs?a=1&a=2 for details.");
153        assert_clean(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"/>"#);
154    }
155
156    #[test]
157    fn rejects_scheme_urls() {
158        assert_clean("https://evil.com/phishing");
159        assert_clean("http://example.com/");
160        assert_clean("file:///etc/passwd");
161    }
162
163    #[test]
164    fn rejects_benign_paths() {
165        assert_clean("example.com/redirect");
166        assert_clean("java script: alert(1)");
167        assert_clean("data:image/png;base64,AA==");
168    }
169
170    #[test]
171    fn rejects_near_misses() {
172        assert_clean("//evil/com");
173        assert_clean("//evil.c");
174        assert_clean("// evil.com");
175    }
176
177    #[test]
178    fn rejects_empty_and_whitespace() {
179        assert_clean("");
180        assert_clean("   ");
181    }
182
183    #[test]
184    fn rejects_unicode_text() {
185        assert_clean("重定向到登录页");
186    }
187}