Skip to main content

security_rust/protocol/
dns_rebinding.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/// 弱信号:`Host:` 头里出现内网 / 环回地址。
9///
10/// 这一族只有「出现」这一个判据,而且它在正常流量里是**常态**,不是异常:
11///
12/// - k8s 里按 Pod IP 互调:`Host: 10.244.1.5:8080`(每一个 pod 间请求都是这个形状)
13/// - 容器网络:`Host: 172.18.0.2:3000`
14/// - 局域网设备(路由器管理页、NAS、打印机):`Host: 192.168.1.1`
15/// - 健康检查与开发服务:`Host: localhost:8000`、`Host: 127.0.0.1`
16///
17/// 这些请求都来自内网,而**真正的 rebinding 恰恰看的是「公网域名 + 解析结果指向
18/// 内网」**:浏览器发出去的是 `Host: attacker.example`,内网服务收到的也正是这个名字。
19/// 单条字符串里看不到解析历史,换句话说本检测器测的形态与攻击形态并不重合,
20/// 收紧无从谈起(收紧成什么样都还是「内网地址出现」)。所以报 Low:仍然检出,
21/// 由调用方按聚合分决定 —— 单条 5 分不越过拒绝线,一堆弱信号叠加才可能升级。
22// 七条同档模式合成一条 alternation:一次 `find` 扫完,而不是逐条 `find` 各扫一遍
23// (干净输入上每条模式都要走到串尾,条数直接乘在开销上)。
24// `(?i)` 为全部七条分支所共有,提到最前——它的作用域是整条模式(含 `|` 之后的全部分支),
25// 与逐条编译时每条各自带 `(?i)` 等价。各分支的完整文本未变,条数从 7 降到 1。
26static WEAK_PATTERNS: LazyLock<Regex> = LazyLock::new(|| {
27    Regex::new(
28        r"(?i)Host:\s*127\.|Host:\s*10\.|Host:\s*192\.168\.|Host:\s*172\.(1[6-9]|2\d|3[01])|Host:\s*localhost|Host:\s*\[::1\]|Host:\s*0\.0\.0\.0",
29    )
30    .unwrap()
31});
32
33pub struct DnsRebindingDetector;
34
35impl Detector for DnsRebindingDetector {
36    fn name(&self) -> &'static str {
37        "dns_rebinding"
38    }
39
40    fn detect(&self, input: &str) -> Option<DetectionResult> {
41        regex_detect(
42            std::slice::from_ref(&*WEAK_PATTERNS),
43            self.name(),
44            AttackCategory::Protocol,
45            Severity::Low,
46            "DNS rebinding signal: Host header names a private address (weak signal)",
47            input,
48        )
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    /// 本检测器整体是弱信号:检出即 `Low`,单独一条不越过拒绝线。
57    fn assert_low(input: &str) {
58        crate::test_helpers::assert_detected(
59            &DnsRebindingDetector,
60            input,
61            AttackCategory::Protocol,
62            Severity::Low,
63        );
64    }
65
66    fn assert_clean(input: &str) {
67        crate::test_helpers::assert_clean(&DnsRebindingDetector, input);
68    }
69
70    #[test]
71    fn name_is_dns_rebinding() {
72        assert_eq!(DnsRebindingDetector.name(), "dns_rebinding");
73    }
74
75    #[test]
76    fn detects_loopback_host() {
77        assert_low("Host: 127.0.0.1");
78    }
79
80    #[test]
81    fn detects_private_hosts() {
82        assert_low("Host: 10.0.0.2");
83        assert_low("Host: 192.168.1.1");
84        assert_low("Host: 172.16.0.1");
85        assert_low("Host: 172.31.255.255");
86    }
87
88    #[test]
89    fn detects_local_names() {
90        assert_low("Host: localhost");
91        assert_low("Host: [::1]");
92        assert_low("Host: 0.0.0.0");
93    }
94
95    #[test]
96    fn detects_mixed_case() {
97        assert_low("host: 127.0.0.1");
98    }
99
100    /// 反向对照:这些是内网流量的**常态**形状 —— 按 Pod IP 互调的 k8s 集群、
101    /// docker compose 网络、局域网路由器,每个请求都长这样。降档前它们全判 High,
102    /// 接入阻断路径等于打死整个内网业务。
103    #[test]
104    fn intranet_host_headers_are_weak_not_high() {
105        for input in [
106            "GET /healthz HTTP/1.1\r\nHost: 10.244.1.5:8080\r\n\r\n",
107            "POST /v1/items HTTP/1.1\r\nHost: 172.18.0.2:3000\r\n\r\n",
108            "GET / HTTP/1.1\r\nHost: 192.168.1.1\r\n\r\n",
109            "GET /metrics HTTP/1.1\r\nHost: localhost:8000\r\n\r\n",
110            "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
111        ] {
112            assert_low(input);
113        }
114    }
115
116    /// 语料里的正常请求(公网 Host + 代理头)仍然干净。
117    #[test]
118    fn proxied_public_request_is_clean() {
119        assert_clean(
120            "GET /index.html HTTP/1.1\r\nHost: example.com\r\nX-Forwarded-For: 203.0.113.7\r\n\r\n",
121        );
122    }
123
124    #[test]
125    fn rejects_public_hosts() {
126        assert_clean("Host: example.com");
127        assert_clean("Host: 8.8.8.8");
128        assert_clean("Host: 172.32.0.1");
129    }
130
131    #[test]
132    fn rejects_hostname_variants() {
133        assert_clean("Hostname: 127.0.0.1");
134        assert_clean("Hostname: localhost");
135    }
136
137    #[test]
138    fn rejects_near_misses() {
139        assert_clean("Host: 12.7.0.1");
140        assert_clean("Host: 172.15.0.1");
141    }
142
143    #[test]
144    fn rejects_empty_and_whitespace() {
145        assert_clean("");
146        assert_clean("   ");
147    }
148
149    #[test]
150    fn rejects_unicode_text() {
151        assert_clean("主机名解析测试,无攻击");
152    }
153}