Skip to main content

security_rust/protocol/
websocket.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/// CSWSH 信号:`Origin: null`。
9///
10/// 浏览器从沙箱 iframe、`data:` URL、部分跨域重定向场景发出的请求会带这个值。
11/// 单独出现不足以判定(某些代理会剥离 Origin,部分合法前端也这么发),
12/// 必须与 WebSocket 升级同时出现才算。
13static ORIGIN_NULL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)Origin:\s*null").unwrap());
14
15/// WebSocket 升级头。
16///
17/// **这是合法握手的必需头,单独出现绝不是攻击** —— 本检测器不使用单独命中。
18static UPGRADE_WS: LazyLock<Regex> =
19    LazyLock::new(|| Regex::new(r"(?i)Upgrade:\s*websocket").unwrap());
20
21/// WebSocket SSRF:`ws://` 指向环回、私网或链路本地地址。
22///
23/// 其中 `169.254.169.254` 是云元数据服务端点,是最常被用来窃取实例凭据的目标。
24/// 这里只匹配非公网目标,`ws://example.com` 不报。
25static WS_INTERNAL: LazyLock<Regex> = LazyLock::new(|| {
26    Regex::new(
27        r"(?i)ws://(?:127\.|localhost|0\.0\.0\.0|\[::1\]|169\.254\.|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)",
28    )
29    .unwrap()
30});
31
32/// WebSocket 攻击检测器。
33///
34/// **设计修正(重要)**:本检测器此前把 `Upgrade: websocket`、`Sec-WebSocket-Key:`、
35/// `ws://` 当作攻击特征,而前两者是**合法握手的必需头**、后者在正常内容里随处可见 ——
36/// 结果是任何合法的 WebSocket 握手都会被判 `Severity::High`,接入阻断路径会打死
37/// 整个 WebSocket 业务。现已收窄为两个真正有信号的形态:
38///
39/// 1. **CSWSH(跨站 WebSocket 劫持)**:`Origin: null` 与 WebSocket 升级同时出现。
40///    这两者需要合取判断,正则表达不了(`regex` crate 无 lookahead),故在
41///    `detect` 里分两步匹配。
42/// 2. **WebSocket SSRF**:`ws://` 指向环回 / 私网 / 链路本地地址(含云元数据端点
43///    `169.254.169.254`)。
44pub struct WebSocketDetector;
45
46impl Detector for WebSocketDetector {
47    fn name(&self) -> &'static str {
48        "websocket"
49    }
50
51    fn detect(&self, input: &str) -> Option<DetectionResult> {
52        let (matched, offset, message) = if let Some(m) = WS_INTERNAL.find(input) {
53            (
54                m.as_str(),
55                m.start(),
56                "WebSocket SSRF: handshake targets a loopback/private address",
57            )
58        } else {
59            // CSWSH 需要两个条件同时成立,且顺序不限(Origin 可能在升级头之前或之后)
60            let origin = ORIGIN_NULL.find(input);
61            let upgrade = UPGRADE_WS.find(input);
62            match (origin, upgrade) {
63                (Some(o), Some(_)) => (
64                    o.as_str(),
65                    o.start(),
66                    "WebSocket hijack attempt (cross-site WebSocket hijacking): Origin: null on a WebSocket upgrade",
67                ),
68                _ => return None,
69            }
70        };
71
72        Some(DetectionResult {
73            attack_type: self.name().to_string(),
74            category: AttackCategory::Protocol,
75            severity: Severity::High,
76            matched_pattern: matched.to_string(),
77            offset,
78            message: message.into(),
79        })
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn assert_detected(input: &str) {
88        crate::test_helpers::assert_detected(
89            &WebSocketDetector,
90            input,
91            AttackCategory::Protocol,
92            Severity::High,
93        );
94    }
95
96    fn assert_clean(input: &str) {
97        crate::test_helpers::assert_clean(&WebSocketDetector, input);
98    }
99
100    #[test]
101    fn name_is_websocket() {
102        assert_eq!(WebSocketDetector.name(), "websocket");
103    }
104
105    #[test]
106    fn detects_origin_null_with_upgrade() {
107        assert_detected("Origin: null\r\nUpgrade: websocket");
108        assert_detected("Upgrade: websocket\r\nOrigin: null");
109        assert_detected(
110            "GET /chat HTTP/1.1\r\nOrigin: null\r\nUpgrade: websocket\r\nSec-WebSocket-Key: abc==",
111        );
112    }
113
114    #[test]
115    fn detects_origin_null_case_insensitive() {
116        assert_detected("origin: NULL upgrade: WebSocket");
117    }
118
119    #[test]
120    fn detects_ws_to_loopback_and_private() {
121        assert_detected("ws://127.0.0.1:8080/socket");
122        assert_detected("ws://localhost/admin");
123        assert_detected("ws://[::1]/x");
124        assert_detected("ws://0.0.0.0/");
125        assert_detected("ws://192.168.1.1/metrics");
126        assert_detected("ws://10.0.0.5/internal");
127        assert_detected("ws://172.16.0.1/");
128        assert_detected("ws://172.31.255.254/");
129    }
130
131    #[test]
132    fn detects_ws_to_cloud_metadata() {
133        // 链路本地地址同时是云元数据端点 —— 窃取实例凭据最常见的路径
134        assert_detected("ws://169.254.169.254/latest/meta-data/");
135    }
136
137    #[test]
138    fn rejects_legitimate_websocket_handshake() {
139        // 这几条是合法握手的必需头。此前它们会被判 High —— 接进阻断路径
140        // 会打死整个 WebSocket 业务。本检测器不再单独命中它们。
141        assert_clean("Upgrade: websocket");
142        assert_clean("upgrade: WebSocket");
143        assert_clean("Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==");
144        assert_clean("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==");
145        assert_clean("SEC-WEBSOCKET-KEY: abc==");
146        assert_clean(
147            "GET /chat HTTP/1.1\r\nHost: example.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13",
148        );
149        assert_clean("Origin: https://example.com\r\nUpgrade: websocket");
150    }
151
152    #[test]
153    fn rejects_public_ws_targets() {
154        assert_clean("ws://example.com/socket");
155        assert_clean("ws://evil.com/socket");
156        assert_clean("wss://example.com/socket");
157        assert_clean("ws://172.15.0.1/x");
158        assert_clean("ws://172.32.0.1/x");
159    }
160
161    #[test]
162    fn rejects_origin_null_alone() {
163        // 单个 Origin: null 不足以判定 —— 某些代理会剥离 Origin
164        assert_clean("Origin: null");
165        assert_clean("Origin: null\r\nHost: example.com");
166    }
167
168    #[test]
169    fn rejects_benign_requests() {
170        assert_clean("Upgrade: h2c");
171        assert_clean("Sec-WebSocket-Protocol: chat");
172        assert_clean("Origin: https://example.com");
173        assert_clean("GET /chat HTTP/1.1\r\nConnection: keep-alive");
174    }
175
176    #[test]
177    fn rejects_empty_and_whitespace() {
178        assert_clean("");
179        assert_clean("   ");
180    }
181
182    #[test]
183    fn rejects_unicode_text() {
184        assert_clean("WebSocket 握手信息");
185    }
186}