Skip to main content

ws_kit/
origin.rs

1//! Origin validation for WebSocket upgrades (Cross-Site WebSocket Hijacking
2//! defense).
3//!
4//! A browser always attaches an `Origin` header to a WebSocket handshake. A
5//! page on any site can open a WebSocket to your endpoint and the browser
6//! will ride the victim's cookies/ambient credentials — unless the server
7//! checks that Origin. [`origin_allowed_in_parts`] validates the `Origin`
8//! header of the upgrade request against a configured allow-list
9//! (`WsConfig::allowed_origins`) **before** `on_upgrade` is called.
10//!
11//! Comparison semantics: scheme + host + port **exact** match after
12//! normalization ([`normalize_origin`]) — lowercase scheme and host, default
13//! ports (`http`/`ws` 80, `https`/`wss` 443) omitted. **No suffix, prefix,
14//! or wildcard matching in v1** — an allow-list entry `https://example.com`
15//! does not accept `https://evil-example.com`, `https://example.com.evil.com`,
16//! or subdomains.
17
18/// Normalizes an Origin header value for comparison.
19///
20/// - scheme and host lowercased (both are case-insensitive)
21/// - default port for the scheme omitted (`https://example.com:443` ==
22///   `https://example.com`)
23/// - bracketed IPv6 hosts supported (`https://[2001:DB8::1]:443` ==
24///   `https://[2001:db8::1]`)
25/// - values without a `scheme://` prefix are only case-normalized — they
26///   will not match a scheme-normalized allow-list entry (fail-closed)
27pub fn normalize_origin(origin: &str) -> String {
28    let origin = origin.trim();
29    let Some((scheme, authority)) = origin.split_once("://") else {
30        return origin.to_ascii_lowercase();
31    };
32    let scheme = scheme.to_ascii_lowercase();
33    let (host, port) = split_authority(authority);
34    let mut out = format!("{scheme}://{}", host.to_ascii_lowercase());
35    if let Some(port) = port {
36        if !port.is_empty() && !is_default_port(&scheme, port) {
37            out.push(':');
38            out.push_str(port);
39        }
40    }
41    out
42}
43
44/// Splits `host[:port]`, handling bracketed IPv6 hosts (`[::1]:8080`).
45fn split_authority(authority: &str) -> (&str, Option<&str>) {
46    if authority.starts_with('[') {
47        if let Some(close) = authority.find(']') {
48            match authority[close + 1..].strip_prefix(':') {
49                Some(port) => return (&authority[..close + 1], Some(port)),
50                None => return (authority, None),
51            }
52        }
53        return (authority, None);
54    }
55    // Non-bracketed hosts cannot contain ':' before the port separator.
56    match authority.split_once(':') {
57        Some((host, port)) => (host, Some(port)),
58        None => (authority, None),
59    }
60}
61
62/// True when `port` is the scheme's default (omitted during normalization).
63fn is_default_port(scheme: &str, port: &str) -> bool {
64    port.parse::<u16>().is_ok_and(|p| {
65        matches!(
66            (scheme, p),
67            ("http", 80) | ("https", 443) | ("ws", 80) | ("wss", 443)
68        )
69    })
70}
71
72/// REQ-WSKIT-200/REQ-WSKIT-201: decides whether a WebSocket upgrade request
73/// may proceed, based on its `Origin`.
74///
75/// - **Empty allow-list (default)**: everything is allowed — including a
76///   missing Origin header (REQ-WSKIT-201, documented residual risk, matches
77///   pre-0.3.0 behavior so existing users don't break).
78/// - **Non-empty allow-list**: the request's Origin must be present and
79///   normalize-match at least one entry exactly — a missing Origin header is
80///   **rejected**; the check must happen before `on_upgrade`.
81///
82/// No wildcard/suffix matching: entries match after normalization only.
83pub fn origin_allowed(origin: Option<&str>, allowed_origins: &[String]) -> bool {
84    if allowed_origins.is_empty() {
85        return true;
86    }
87    let Some(origin) = origin else {
88        return false;
89    };
90    let normalized = normalize_origin(origin);
91    allowed_origins
92        .iter()
93        .any(|entry| normalize_origin(entry) == normalized)
94}
95
96/// REQ-WSKIT-200: [`origin_allowed`] over the `Origin` header of an upgrade
97/// request. Call this in the upgrade handler **before** `WebSocketUpgrade::
98/// on_upgrade`; a `false` result should map to `403 Forbidden`.
99#[cfg(feature = "axum")]
100pub fn origin_allowed_in_parts(parts: &http::request::Parts, allowed_origins: &[String]) -> bool {
101    let origin = parts
102        .headers
103        .get(http::header::ORIGIN)
104        .and_then(|value| value.to_str().ok());
105    origin_allowed(origin, allowed_origins)
106}
107
108// Tests exercise failure paths and invariants directly; unwrap/expect,
109// slicing, and panicking asserts are acceptable here — violations
110// surface as test failures, not production panics.
111#[allow(
112    clippy::unwrap_used,
113    clippy::expect_used,
114    clippy::indexing_slicing,
115    clippy::panic
116)]
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn normalize_origin_basic_and_case() {
123        assert_eq!(
124            normalize_origin("https://example.com"),
125            "https://example.com"
126        );
127        assert_eq!(
128            normalize_origin("HTTPS://EXAMPLE.COM"),
129            "https://example.com"
130        );
131        assert_eq!(
132            normalize_origin("https://EXAMPLE.com"),
133            "https://example.com"
134        );
135        assert_eq!(
136            normalize_origin("  https://example.com  "),
137            "https://example.com"
138        );
139    }
140
141    #[test]
142    fn normalize_origin_default_ports_omitted() {
143        assert_eq!(
144            normalize_origin("https://example.com:443"),
145            "https://example.com"
146        );
147        assert_eq!(
148            normalize_origin("http://example.com:80"),
149            "http://example.com"
150        );
151        assert_eq!(
152            normalize_origin("wss://example.com:443"),
153            "wss://example.com"
154        );
155        assert_eq!(normalize_origin("ws://example.com:80"), "ws://example.com");
156    }
157
158    #[test]
159    fn normalize_origin_custom_ports_kept() {
160        assert_eq!(
161            normalize_origin("https://example.com:8443"),
162            "https://example.com:8443"
163        );
164        // Port differences must NOT collapse: different origin.
165        assert_ne!(
166            normalize_origin("https://example.com:8443"),
167            normalize_origin("https://example.com")
168        );
169        assert_ne!(
170            normalize_origin("http://example.com:8080"),
171            normalize_origin("http://example.com")
172        );
173    }
174
175    #[test]
176    fn normalize_origin_ipv6() {
177        assert_eq!(
178            normalize_origin("https://[2001:DB8::1]:443"),
179            "https://[2001:db8::1]"
180        );
181        assert_eq!(normalize_origin("http://[::1]:8080"), "http://[::1]:8080");
182        assert_eq!(normalize_origin("http://[::1]"), "http://[::1]");
183    }
184
185    #[test]
186    fn normalize_origin_no_scheme_fail_closed() {
187        // Malformed values do not gain a scheme — they cannot masquerade as
188        // a scheme-normalized allow-list entry.
189        assert_eq!(normalize_origin("example.com"), "example.com");
190        assert_eq!(normalize_origin("localhost:3000"), "localhost:3000");
191    }
192
193    #[test]
194    fn origin_allowed_empty_list_allows_all() {
195        // REQ-WSKIT-201: default behavior — allow all, even missing Origin.
196        assert!(origin_allowed(Some("https://anything.dev"), &[]));
197        assert!(origin_allowed(None, &[]));
198    }
199
200    #[test]
201    fn origin_allowed_match_and_normalization() {
202        let allowed = vec!["https://example.com".to_string()];
203        assert!(origin_allowed(Some("https://example.com"), &allowed));
204        // default-port equivalence
205        assert!(origin_allowed(Some("https://example.com:443"), &allowed));
206        // case-insensitive host and scheme
207        assert!(origin_allowed(Some("HTTPS://EXAMPLE.COM"), &allowed));
208        // allow-list entries are normalized the same way
209        let allowed443 = vec!["https://example.com:443".to_string()];
210        assert!(origin_allowed(Some("https://example.com"), &allowed443));
211    }
212
213    #[test]
214    fn origin_allowed_missing_origin_rejected_when_listed() {
215        let allowed = vec!["https://example.com".to_string()];
216        assert!(!origin_allowed(None, &allowed));
217    }
218
219    #[test]
220    fn origin_allowed_no_wildcard_or_suffix_matching() {
221        let allowed = vec!["https://example.com".to_string()];
222        assert!(!origin_allowed(Some("https://evil-example.com"), &allowed));
223        assert!(!origin_allowed(
224            Some("https://example.com.evil.com"),
225            &allowed
226        ));
227        assert!(!origin_allowed(Some("https://api.example.com"), &allowed));
228        assert!(!origin_allowed(Some("https://example.com:8443"), &allowed));
229    }
230
231    #[test]
232    fn origin_allowed_scheme_matters() {
233        let allowed = vec!["https://example.com".to_string()];
234        assert!(!origin_allowed(Some("http://example.com"), &allowed));
235        assert!(!origin_allowed(Some("ws://example.com"), &allowed));
236    }
237
238    #[cfg(feature = "axum")]
239    mod parts_tests {
240        use super::*;
241        use http::{HeaderMap, Request};
242
243        fn parts_with_origin(origin: Option<&str>) -> http::request::Parts {
244            let mut builder = Request::builder().uri("/ws");
245            if let Some(o) = origin {
246                builder = builder.header(http::header::ORIGIN, o);
247            }
248            let (parts, _) = builder.body(()).unwrap().into_parts();
249            parts
250        }
251
252        #[test]
253        fn origin_allowed_in_parts_reads_header() {
254            let allowed = vec!["https://example.com".to_string()];
255            assert!(origin_allowed_in_parts(
256                &parts_with_origin(Some("https://example.com")),
257                &allowed
258            ));
259            assert!(!origin_allowed_in_parts(
260                &parts_with_origin(Some("https://evil.com")),
261                &allowed
262            ));
263            assert!(!origin_allowed_in_parts(&parts_with_origin(None), &allowed));
264            assert!(origin_allowed_in_parts(&parts_with_origin(None), &[]));
265        }
266
267        #[test]
268        fn origin_allowed_in_parts_host_header_ignored() {
269            // A Host header must not stand in for Origin.
270            let allowed = vec!["https://example.com".to_string()];
271            let mut headers = HeaderMap::new();
272            headers.insert(http::header::HOST, "example.com".parse().unwrap());
273            let req = Request::builder().uri("/ws").body(()).unwrap();
274            let (mut parts, _) = req.into_parts();
275            parts.headers = headers;
276            assert!(!origin_allowed_in_parts(&parts, &allowed));
277        }
278    }
279}