1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity, regex_detect};
7
8static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
21 Regex::new(
22 &[
23 r"(?i)169\.254\.169\.254",
26 r"|metadata\.google\.internal",
27 r"|//10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
28 r"|//172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}",
29 r"|//192\.168\.\d{1,3}\.\d{1,3}",
30 r"|//127\.\d{1,3}(?:\.\d{1,3}){0,2}",
32 r"|//localhost\b",
39 r"|//0\.0\.0\.0",
44 r"|//\[::1\]",
45 r"|gopher://",
46 r"|dict://",
47 r"|ftp://[^/]*@",
48 r"|file:///",
49 ]
50 .concat(),
51 )
52 .unwrap()
53});
54
55static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
66 Regex::new(
67 &[
68 r"(?i)10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
69 r"|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}",
70 r"|192\.168\.\d{1,3}\.\d{1,3}",
71 r"|127\.\d{1,3}(?:\.\d{1,3}){0,2}",
72 r"|0\.0\.0\.0",
73 r"|\[::1\]",
74 ]
75 .concat(),
76 )
77 .unwrap()
78});
79
80pub struct SsrfDetector;
93
94impl Detector for SsrfDetector {
95 fn name(&self) -> &'static str {
96 "ssrf"
97 }
98
99 fn detect(&self, input: &str) -> Option<DetectionResult> {
100 regex_detect(
101 std::slice::from_ref(&*STRONG_PATTERNS),
102 self.name(),
103 AttackCategory::Protocol,
104 Severity::Critical,
105 "SSRF server-side request forgery detected",
106 input,
107 )
108 .or_else(|| {
109 regex_detect(
110 std::slice::from_ref(&*WEAK_PATTERNS),
111 self.name(),
112 AttackCategory::Protocol,
113 Severity::Low,
114 "internal address literal present (weak signal)",
115 input,
116 )
117 })
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 fn assert_detected(input: &str) {
126 crate::test_helpers::assert_detected(
127 &SsrfDetector,
128 input,
129 AttackCategory::Protocol,
130 Severity::Critical,
131 );
132 }
133
134 fn assert_clean(input: &str) {
135 crate::test_helpers::assert_clean(&SsrfDetector, input);
136 }
137
138 fn assert_low(input: &str) {
139 crate::test_helpers::assert_detected(
140 &SsrfDetector,
141 input,
142 AttackCategory::Protocol,
143 Severity::Low,
144 );
145 }
146
147 #[test]
148 fn name_is_ssrf() {
149 assert_eq!(SsrfDetector.name(), "ssrf");
150 }
151
152 #[test]
153 fn detects_cloud_metadata_ip() {
154 assert_detected("http://169.254.169.254/latest/meta-data/");
155 }
156
157 #[test]
158 fn detects_internal_ipv4() {
159 assert_detected("http://10.0.0.1/admin");
160 }
161
162 #[test]
163 fn detects_private_ranges() {
164 assert_detected("http://192.168.1.1/");
165 assert_detected("http://172.16.0.1/");
166 assert_detected("http://172.31.255.255/");
167 assert_detected("http://127.0.0.1:8080/");
168 assert_detected("http://0.0.0.0/");
169 assert_detected("http://[::1]/");
170 }
171
172 #[test]
173 fn detects_ssrf_uri_schemes() {
174 assert_detected("gopher://evil.com/_GET / HTTP/1.1");
175 assert_detected("dict://evil.com:11211/");
176 assert_detected("ftp://user@evil.com/file");
177 assert_detected("file:///etc/passwd");
178 }
179
180 #[test]
181 fn detects_mixed_case_schemes() {
182 assert_detected("GOPHER://evil.com/");
183 assert_detected("FILE:///etc/shadow");
184 }
185
186 #[test]
187 fn detects_loopback_hostname_and_short_forms() {
188 assert_detected("http://localhost/admin");
189 assert_detected("http://localhost:8080/");
190 assert_detected("http://127.1/");
191 assert_detected(r#"{"url": "http://localhost"}"#);
193 }
194
195 #[test]
196 fn detects_cloud_metadata_hostname() {
197 assert_detected("http://metadata.google.internal/computeMetadata/v1/");
198 }
199
200 #[test]
201 fn rejects_public_hosts() {
202 assert_clean("http://example.com/index.html");
203 assert_clean("https://github.com/security-rust");
204 assert_clean("http://172.32.0.1/");
205 assert_clean("http://8.8.8.8/dns");
206 }
207
208 #[test]
213 fn bare_internal_literals_are_weak_not_critical() {
214 for input in [
215 "GET /api HTTP/1.1\r\nHost: api.internal\r\nX-Forwarded-For: 10.0.0.5\r\nX-Real-IP: 10.0.0.5\r\n\r\n",
217 "GET /healthz HTTP/1.1\r\nHost: 10.244.1.5:8080\r\n\r\n",
219 "POST /v1/items HTTP/1.1\r\nHost: 172.18.0.2:3000\r\n\r\n",
220 "GET / HTTP/1.1\r\nHost: 192.168.1.1\r\n\r\n",
222 "server {\n listen 0.0.0.0:80;\n}\n",
224 "server {\n listen [::1]:8080;\n}\n",
225 "if __name__ == \"__main__\":\n app.run(host=\"0.0.0.0\", port=5000)\n",
226 "bind 127.0.0.1 -::1\nprotected-mode yes\n",
227 r#"{"host": "10.0.0.1", "port": 6379}"#,
230 "postgres://app:pw@10.0.0.5:5432/db",
234 "http://user:pass@127.0.0.1/",
235 ] {
236 assert_low(input);
237 }
238 }
239
240 #[test]
242 fn url_context_internal_targets_stay_critical() {
243 for input in [
244 "curl http://10.0.0.1:8080/admin",
245 "http://192.168.0.10/cgi-bin/status",
246 "http://172.20.0.3:9200/_cat/indices",
247 "http://127.1/",
248 "http://[::1]:9200/",
249 "http://0.0.0.0:8080/",
250 "gopher://10.0.0.1:6379/_INFO",
251 ] {
252 assert_detected(input);
253 }
254 }
255
256 #[test]
257 fn rejects_localhost_lookalikes() {
258 assert_clean("mongodb://admin:password@localhost:27017/db");
260 assert_clean("http://internal.example.com/api");
261 assert_clean("metadata.google.com 是普通域名");
262 assert_clean("localhost 只是文档里的一个词");
263 }
264
265 #[test]
266 fn rejects_near_misses() {
267 assert_clean("http://10.0.0/admin");
268 assert_clean("http://169.254.169/admin");
269 assert_clean("ftp://evil.com/pub");
270 assert_clean("http://192.168/admin");
271 }
272
273 #[test]
274 fn rejects_empty_and_whitespace() {
275 assert_clean("");
276 assert_clean(" ");
277 assert_clean("\t\n");
278 }
279
280 #[test]
281 fn rejects_unicode_text() {
282 assert_clean("你好,世界!这是一个正常的中文文本。");
283 }
284}