Skip to main content

forge_server/transport/
origin.rs

1//! The socket's Origin gate.
2//!
3//! A browser page the user did not point at forge can open `ws://` to the
4//! loopback socket from any tab, and the socket carries the whole effective
5//! config - credentials included. Browsers always send an `Origin` on a
6//! WebSocket handshake and pages cannot forge it, so the header is the line
7//! between the pages the user meant to reach forge and a drive-by page: the
8//! clients forge ships, and whatever the user is serving on their own
9//! machine.
10
11use std::net::IpAddr;
12
13use axum::http::Uri;
14use forge_primitives::server::ServerConfig;
15
16/// The origins a Tauri webview is served from: `tauri://localhost` on macOS,
17/// `http://tauri.localhost` on Android and Windows, `https://tauri.localhost`
18/// where the scheme is https.
19const SHELL_ORIGINS: &[&str] =
20    &["tauri://localhost", "http://tauri.localhost", "https://tauri.localhost"];
21
22/// Whether `origin` is a page served on the user's own machine: an http or
23/// https origin whose host is loopback, at any port, in every build.
24///
25/// The host is read from a parsed URI, never matched as a substring: a host
26/// that merely contains a loopback name, like `localhost.evil.com`, is its
27/// own host and stays refused. The authority must be a host and an optional
28/// numeric port exactly, because `http`'s own `host()` stops at the first
29/// `]` and would read `[::1].evil.com` as `[::1]`.
30fn is_loopback_origin(origin: &str) -> bool {
31    let Ok(uri) = origin.parse::<Uri>() else { return false };
32    let web_scheme = uri.scheme_str().is_some_and(|scheme| {
33        scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
34    });
35    if !web_scheme {
36        return false;
37    }
38    let Some(authority) = uri.authority() else { return false };
39    let host = authority.host();
40    let Some(rest) = authority.as_str().strip_prefix(host) else { return false };
41    let port_only = rest.is_empty()
42        || rest
43            .strip_prefix(':')
44            .is_some_and(|port| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()));
45    port_only && is_loopback_host(host)
46}
47
48/// Whether `host` is loopback: the `localhost` name, or any address in a
49/// loopback range. Every form counts, not three literals: `127.0.0.2`, an
50/// expanded `[0:0:0:0:0:0:0:1]` and an IPv4-mapped `[::ffff:127.0.0.1]` are
51/// all this machine.
52fn is_loopback_host(host: &str) -> bool {
53    if host.eq_ignore_ascii_case("localhost") {
54        return true;
55    }
56    let literal = host.strip_prefix('[').and_then(|host| host.strip_suffix(']')).unwrap_or(host);
57    literal.parse::<IpAddr>().is_ok_and(|address| match address {
58        IpAddr::V4(address) => address.is_loopback(),
59        IpAddr::V6(address) => address.to_canonical().is_loopback(),
60    })
61}
62
63/// Whether a request's `Origin` may open the socket.
64///
65/// A request with none is a native client - the protocol client, a harness,
66/// `wscat` - and browsers never omit the header, so absence is allowed and
67/// a present origin is what the gate reads.
68pub(crate) fn allows(origin: Option<&str>, server: &ServerConfig) -> bool {
69    let Some(origin) = origin else { return true };
70    let origin = origin.trim_end_matches('/');
71    if SHELL_ORIGINS.contains(&origin) {
72        return true;
73    }
74    if is_loopback_origin(origin) {
75        return true;
76    }
77    if server.allowed_origins.iter().any(|allowed| allowed.trim_end_matches('/') == origin) {
78        return true;
79    }
80    own_origins(server).iter().any(|allowed| allowed == origin)
81}
82
83/// The origins the server itself is reached at, from `[server] bind` and
84/// `[server] port`. No port is hardcoded: a web client served from the
85/// server's own address is the one origin the config knows.
86fn own_origins(server: &ServerConfig) -> Vec<String> {
87    let port = server.port;
88    match server.bind {
89        IpAddr::V4(ip) if ip.is_loopback() || ip.is_unspecified() => {
90            vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}")]
91        }
92        IpAddr::V6(ip) if ip.is_loopback() || ip.is_unspecified() => {
93            vec![format!("http://[::1]:{port}"), format!("http://localhost:{port}")]
94        }
95        IpAddr::V4(ip) => vec![format!("http://{ip}:{port}")],
96        IpAddr::V6(ip) => vec![format!("http://[{ip}]:{port}")],
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use std::net::{Ipv4Addr, Ipv6Addr};
103
104    use super::*;
105
106    fn config() -> ServerConfig {
107        ServerConfig { port: 9001, ..ServerConfig::default() }
108    }
109
110    /// A page forge did not ship is refused; the tab in the user's browser is
111    /// the drive-by this exists for.
112    #[test]
113    fn a_foreign_origin_is_refused() {
114        assert!(!allows(Some("https://evil.example"), &config()));
115    }
116
117    /// Any page the user serves on their own machine may attach: a loopback
118    /// host in any of its forms, either web scheme, any port. Neither the port
119    /// nor an allowed origin is in the config, so only the loopback rule can
120    /// allow these.
121    #[test]
122    fn a_loopback_origin_on_any_port_is_allowed() {
123        for origin in [
124            "http://localhost:53219",
125            "https://localhost:53219",
126            "http://127.0.0.1:53219",
127            "https://127.0.0.1:53219",
128            "http://[::1]:53219",
129            "https://[::1]:53219",
130            "http://127.0.0.2:53219",
131            "http://[0:0:0:0:0:0:0:1]:53219",
132            "http://[::ffff:127.0.0.1]:53219",
133            "http://localhost",
134            "http://LOCALHOST:53219",
135        ] {
136            assert!(allows(Some(origin), &config()), "{origin}");
137        }
138    }
139
140    /// The check is on the parsed host, not a substring: a host that merely
141    /// contains a loopback name is a page from somewhere else, and an
142    /// authority is a host and an optional numeric port exactly - `http`'s
143    /// own `host()` stops at the first `]` and would read `[::1].evil.com` as
144    /// `[::1]`.
145    #[test]
146    fn a_lookalike_host_is_refused() {
147        for origin in [
148            "http://localhost.evil.com:53219",
149            "https://127.0.0.1.nip.io:53219",
150            "http://localhost@evil.com",
151            "http://[::1].evil.com",
152            "http://[::1]evil.com",
153            "http://[::1]:notaport",
154        ] {
155            assert!(!allows(Some(origin), &config()), "{origin}");
156        }
157    }
158
159    /// The loopback allowance is for a page's web origin: another scheme, a
160    /// bare authority and a sandboxed frame's `null` are not one.
161    #[test]
162    fn a_non_web_origin_is_refused() {
163        for origin in ["ws://localhost:53219", "localhost:53219", "null"] {
164            assert!(!allows(Some(origin), &config()), "{origin}");
165        }
166    }
167
168    /// The shell's own webview origin is allowed on both schemes and on the
169    /// Android host.
170    #[test]
171    fn the_shells_origins_are_allowed() {
172        for origin in SHELL_ORIGINS {
173            assert!(allows(Some(origin), &config()), "{origin}");
174        }
175    }
176
177    /// The server's own origin is derived from the config's bind and port: a
178    /// network bind is reached at its own address there, and neither a
179    /// neighbouring address nor a different port is the server.
180    #[test]
181    fn the_servers_own_origin_comes_from_its_bind_and_port() {
182        let server = ServerConfig { bind: IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), ..config() };
183        assert!(allows(Some("http://10.0.0.5:9001"), &server));
184        assert!(!allows(Some("http://10.0.0.5:9002"), &server));
185        assert!(!allows(Some("http://10.0.0.6:9001"), &server));
186        let server = ServerConfig {
187            bind: IpAddr::V6(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)),
188            ..config()
189        };
190        assert!(allows(Some("http://[fe80::1]:9001"), &server));
191        assert!(!allows(Some("http://[fe80::2]:9001"), &server));
192    }
193
194    /// An origin the operator listed is allowed, with a trailing slash
195    /// tolerated. The host is not loopback, so the list is what allows it.
196    #[test]
197    fn a_configured_origin_is_allowed() {
198        let server = ServerConfig {
199            allowed_origins: vec!["http://forge-web.example:8080".to_owned()],
200            ..config()
201        };
202        assert!(allows(Some("http://forge-web.example:8080"), &server));
203        assert!(allows(Some("http://forge-web.example:8080/"), &server));
204        assert!(!allows(Some("http://forge-web.example:8081"), &server));
205    }
206
207    /// A client that sends no Origin is a native one, not a browser page.
208    #[test]
209    fn an_absent_origin_is_a_native_client() {
210        assert!(allows(None, &config()));
211    }
212}