Skip to main content

reddb_wire/redwire/
ws_gate.rs

1//! Pure WebSocket upgrade-gate policy for the RedWire-over-WSS edge
2//! (issue #935, ADR 0036).
3//!
4//! A browser cannot speak RedWire-over-TCP, so it upgrades a binary
5//! WebSocket on the TLS edge. WebSocket is *not* covered by CORS, so the
6//! upgrade is **default-deny** and validated here: TLS-only, then an
7//! exact-match `Origin` allowlist (Cross-Site WebSocket Hijacking
8//! defence).
9//!
10//! This module is transport-agnostic and free of axum/HTTP types so the
11//! security decision is unit-testable without a live TLS edge or socket.
12//! The server's `ws_edge` axum handler maps its `Origin`/transport into
13//! these inputs and renders the refusal as an HTTP response — it owns the
14//! I/O, reddb-wire owns the policy.
15
16/// Why a RedWire WebSocket upgrade was refused (ADR 0036).
17///
18/// Checks are ordered so the TLS gate precedes the origin checks: an
19/// upgrade on the clear-text edge is rejected before the allowlist is
20/// even consulted.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum WsUpgradeRefusal {
23    /// Arrived on a non-TLS edge — `ws://` is never accepted (WSS-only).
24    NotTls,
25    /// No `Origin` header — a browser always sends one; its absence is a
26    /// non-browser caller or a stripped header. Reject.
27    OriginMissing,
28    /// `Origin` is not on the configured allowlist, or it carried a
29    /// smuggled CR/LF and so can never match a well-formed allowlist
30    /// entry.
31    OriginRejected,
32}
33
34/// Evaluate the RedWire WS upgrade gate (ADR 0036). Pure: TLS-only, then
35/// an exact-match `Origin` against the allowlist.
36///
37/// * `is_tls_edge` — `true` only when the request arrived on the TLS
38///   listener; a clear-text edge is always refused with [`WsUpgradeRefusal::NotTls`].
39/// * `origin` — the request's `Origin` header, if any. Absence is refused
40///   ([`WsUpgradeRefusal::OriginMissing`]); an `Origin` carrying a CR or
41///   LF is treated as a header-smuggling attempt and refused
42///   ([`WsUpgradeRefusal::OriginRejected`]).
43/// * `allowlist` — exact-match origins. An empty allowlist denies every
44///   origin (default-deny).
45pub fn evaluate_ws_upgrade(
46    is_tls_edge: bool,
47    origin: Option<&str>,
48    allowlist: &[String],
49) -> Result<(), WsUpgradeRefusal> {
50    if !is_tls_edge {
51        return Err(WsUpgradeRefusal::NotTls);
52    }
53    match origin {
54        None => Err(WsUpgradeRefusal::OriginMissing),
55        Some(o) if o.bytes().any(|b| b == b'\r' || b == b'\n') => {
56            Err(WsUpgradeRefusal::OriginRejected)
57        }
58        Some(o) if allowlist.iter().any(|allowed| allowed == o) => Ok(()),
59        Some(_) => Err(WsUpgradeRefusal::OriginRejected),
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn allowlist() -> Vec<String> {
68        vec![
69            "https://app.example.com".to_string(),
70            "https://admin.example.com".to_string(),
71        ]
72    }
73
74    #[test]
75    fn allowed_origin_over_tls_is_accepted() {
76        assert_eq!(
77            evaluate_ws_upgrade(true, Some("https://app.example.com"), &allowlist()),
78            Ok(())
79        );
80    }
81
82    #[test]
83    fn non_tls_edge_is_refused_before_origin_is_consulted() {
84        // WSS-only: the TLS check precedes the origin check, so an
85        // otherwise-allowed origin on the clear-text edge still fails.
86        assert_eq!(
87            evaluate_ws_upgrade(false, Some("https://app.example.com"), &allowlist()),
88            Err(WsUpgradeRefusal::NotTls)
89        );
90    }
91
92    #[test]
93    fn missing_origin_is_refused() {
94        assert_eq!(
95            evaluate_ws_upgrade(true, None, &allowlist()),
96            Err(WsUpgradeRefusal::OriginMissing)
97        );
98    }
99
100    #[test]
101    fn empty_allowlist_denies_every_origin() {
102        assert_eq!(
103            evaluate_ws_upgrade(true, Some("https://app.example.com"), &[]),
104            Err(WsUpgradeRefusal::OriginRejected)
105        );
106    }
107
108    #[test]
109    fn origin_match_is_exact_not_prefix_or_suffix() {
110        assert_eq!(
111            evaluate_ws_upgrade(true, Some("https://app.example.com.evil.com"), &allowlist()),
112            Err(WsUpgradeRefusal::OriginRejected)
113        );
114        assert_eq!(
115            evaluate_ws_upgrade(true, Some("https://app.example.co"), &allowlist()),
116            Err(WsUpgradeRefusal::OriginRejected)
117        );
118    }
119
120    #[test]
121    fn crlf_smuggled_origin_is_rejected() {
122        // A CR/LF-bearing Origin must never match — it is a header
123        // injection / smuggling attempt, refused outright.
124        for smuggled in [
125            "https://app.example.com\r\nX-Injected: 1",
126            "https://app.example.com\n",
127            "https://app.example.com\r",
128        ] {
129            assert_eq!(
130                evaluate_ws_upgrade(true, Some(smuggled), &allowlist()),
131                Err(WsUpgradeRefusal::OriginRejected)
132            );
133        }
134    }
135}