security_rust/protocol/
websocket.rs1use regex::Regex;
4use std::sync::LazyLock;
5
6use crate::{AttackCategory, DetectionResult, Detector, Severity};
7
8static ORIGIN_NULL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)Origin:\s*null").unwrap());
14
15static UPGRADE_WS: LazyLock<Regex> =
19 LazyLock::new(|| Regex::new(r"(?i)Upgrade:\s*websocket").unwrap());
20
21static 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
32pub 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 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 assert_detected("ws://169.254.169.254/latest/meta-data/");
135 }
136
137 #[test]
138 fn rejects_legitimate_websocket_handshake() {
139 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 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}