security_rust/protocol/
open_redirect.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity};
7
8static 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
30static 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 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 #[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); }
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 #[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 #[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 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}