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}