Skip to main content

security_rust/protocol/
ssrf.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, regex_detect};
7
8/// 强信号:攻击形态受约束 —— 内网目标出现在 **URL authority 位置**(`//` 之后),
9/// 或者字面量本身就是只为 SSRF 存在的东西(云元数据端点、危险协议)。
10///
11/// 为什么内网字面量要求 `//` 前缀:SSRF 的载荷一定是「往哪发」,即 URL;
12/// 而同一个 `10.0.0.5` 出现在配置、日志、代理头里时是基础设施的日常词汇 —
13/// 内网 LB 加的 `X-Forwarded-For: 10.0.0.5`、k8s 按 Pod IP 互调的
14/// `Host: 10.244.1.5:8080`、docker 网络的 `172.18.0.2`。
15/// 非 URL 位置的那一份降为弱信号(见 `WEAK_PATTERNS`),不删除。
16// 十三条同档模式合成一条 alternation:干净输入上每条模式都要走到串尾,条数直接乘在
17// 单次 `find` 的开销上;合并后一次扫描扫完十三条分支。各分支的完整文本未变,只多了 `|`。
18// `(?i)` 为十三条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
19// 与逐条编译时每条各自带 `(?i)` 等价。
20static STRONG_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(
22        &[
23            // 云元数据:地址与它的 GCP 域名等价物。任何正常内容里都不该出现,
24            // 且没有「配置写法」——所以这一条不要求 URL 上下文。
25            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            // 段数放宽到 2~4:`127.1` / `127.0.0.1` 都指向环回。
31            r"|//127\.\d{1,3}(?:\.\d{1,3}){0,2}",
32            // 主机名形态的环回只在 URL authority 位置认(`//localhost`),与 websocket.rs 的
33            // `ws://(?:...|localhost|...)` 同一口径。裸 `localhost` 会把
34            // `mongodb://user@localhost:27017/db` 这类连接串也判成 Critical —— 那是 data_leak
35            // 的地盘,scanner 里 ssrf 排在 data_leak 前面,会把它的结果顶掉。
36            // 代价(已知缺口): `http://user@localhost/` 带 userinfo 的、以及无 scheme 的
37            // `localhost:8080/` 认不出来 —— websocket.rs 的 `ws://user@localhost` 同样漏。
38            r"|//localhost\b",
39            // `0.0.0.0` / `[::1]` 同理:只在 URL authority 位置认。
40            // 裸形态是配置里的**监听地址** —— `listen 0.0.0.0:80;`(nginx 默认站点)、
41            // `app.run(host="0.0.0.0")`(Flask 教程)、`bind 0.0.0.0`(redis.conf)、
42            // `listen [::1]:8080;` —— 全是正常内容。
43            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
55/// 弱信号:内网 / 环回字面量出现在**非 URL 位置**。
56///
57/// 同一串字节在两种上下文里含义相反,而正则只看得见字节:
58/// - 攻击侧:`{"host": "10.0.0.1", "port": 6379}` —— 目标主机与端口分开传的 SSRF
59/// - 正常侧:`X-Forwarded-For: 10.0.0.5`(内网 LB 加的)、`Host: 10.244.1.5`(k8s)、
60///   `bind 127.0.0.1`(redis.conf 默认值)、`127.0.0.1 localhost`(/etc/hosts)
61///
62/// 分不开,所以报 Low:仍然检出,单条 5 分不越过拒绝线,聚合分交给调用方。
63// 六条同档模式合成一条 alternation,理由与 `STRONG_PATTERNS` 相同:条数从 6 降到 1。
64// 分支顺序与文本都与逐条时一致。
65static 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
80// 已知缺口(有意保留,别当 bug 修):
81//
82// 1. 环回的进制/映射变形 —— 十进制 `2130706433`、八进制 `0177.0.0.1`、十六进制
83//    `0x7f.0.0.1`、IPv6 映射 `::ffff:127.0.0.1` —— 都要先把字面量归一化再比范围,
84//    正则做不了这件事。只匹配字面量是本检测器的设计边界。
85// 2. 非 URL 位置的内网字面量只到 `Severity::Low`(见 `WEAK_PATTERNS`):
86//    `{"host": "10.0.0.1", "port": 6379}` 这类「主机与端口分开传」的载荷,
87//    与 `X-Forwarded-For: 10.0.0.5`、`bind 127.0.0.1` 逐字节同形,靠单条字符串
88//    分不开;带 userinfo 的连接串(`postgres://app:pw@10.0.0.5/db`)同理。
89//    要提升这一档只能由调用方按聚合分决定。
90//
91// 详见 docs/OWASP-COVERAGE.md「缺口」一节。
92pub 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        // JSON 里主机名后面跟的是引号而不是 / 或 :,也要认
192        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    /// 反向对照:收紧 localhost 之后不能把「长得像 localhost」的正常目标也打上
209    /// 反向对照:内网字面量出现在**非 URL** 位置时是基础设施的日常词汇。
210    /// 收紧前这些全判 Critical(单条即拒绝)——「内网 LB 加代理头」和
211    /// 「k8s 按 Pod IP 互调」是生产环境每个请求的默认形状。
212    #[test]
213    fn bare_internal_literals_are_weak_not_critical() {
214        for input in [
215            // 内网 LB / 反代自动添加的头
216            "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            // k8s 按 Pod IP 互调、docker 网络
218            "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            // 局域网设备
221            "GET / HTTP/1.1\r\nHost: 192.168.1.1\r\n\r\n",
222            // 监听地址:nginx 默认站点、Flask 教程、redis.conf 默认值、/etc/hosts
223            "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            // 弱层的攻击侧同样在这一档:主机与端口分开传的 SSRF 载荷,
228            // 字节上与上面的配置无异,所以只报 Low(仍然检出)
229            r#"{"host": "10.0.0.1", "port": 6379}"#,
230            // 带 userinfo 的连接串:正常配置(内网 DB 连接串)与绕白名单的 SSRF
231            // 载荷逐字节同形 —— `postgres://app:pw@10.0.0.5/db` /
232            // `http://user:pass@127.0.0.1/`,同样只报 Low
233            "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    /// 收紧的另一半:URL 上下文里的同一批目标必须照报 Critical。
241    #[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        // 连接串里的 localhost 归 data_leak 管,ssrf 不该抢(authority 前是 userinfo)
259        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}