rahti_native/gate.rs
1//! Keeping the loopback server to the application that started it.
2//!
3//! ## The threat
4//!
5//! `127.0.0.1` is not a private address. It is unreachable from the network
6//! and reachable by *every process on the machine* — every other application
7//! the user installed, every browser tab that can be persuaded to fetch a
8//! local URL, every script they were talked into running. A packaged Rahti
9//! application listening on a loopback port is a signed-in session, an upload
10//! endpoint and a database, exposed to all of them.
11//!
12//! The port is assigned by the operating system, which helps and does not
13//! solve it: 65535 ports is a few seconds of scanning, and Rahti's own pages
14//! identify themselves.
15//!
16//! ## The answer, and why it is this one
17//!
18//! A random token is minted per launch. The host opens
19//! `http://127.0.0.1:<port>/?__rahti_native=<token>`; this layer recognizes
20//! it, sets it as a cookie, and redirects to the clean URL. Every later
21//! request — pages, assets, `pp.rpc`, a streaming response, a multipart
22//! upload, a WebSocket handshake — carries the cookie because the browser
23//! carries cookies, and is allowed. A request without it gets 403 and nothing
24//! else.
25//!
26//! A cookie rather than a header is the whole design. A header can be attached
27//! to `fetch` and to nothing else: a header scheme would allow RPCs and refuse
28//! the document, the stylesheet, the PulsePoint bundle, an `<img>`, a file
29//! download and the WebSocket upgrade. The cookie is the one credential the
30//! browser attaches to *every* kind of request a Rahti page makes, which is
31//! why this scheme costs no feature.
32//!
33//! ## What it is not
34//!
35//! It is not authentication, and it does not replace CSRF. It answers "is this
36//! request from the WebView this application opened", and Rahti's own layers
37//! still answer "is there a session" and "did this call come from a page of
38//! ours". A local attacker who can read the application's own cookie jar has
39//! already won and this does not pretend otherwise.
40
41use std::sync::OnceLock;
42
43use axum::extract::Request;
44use axum::http::{HeaderValue, StatusCode, header};
45use axum::middleware::Next;
46use axum::response::{IntoResponse, Response};
47
48/// The query parameter the launch URL carries, and the cookie it becomes.
49pub const LAUNCH_PARAM: &str = "__rahti_native";
50
51/// This launch's token.
52///
53/// Generated once per process, on first use. A new one every launch is the
54/// point: a token that survived a restart would be a token that could be read
55/// off disk.
56///
57/// If the operating system will not provide randomness — which on the
58/// platforms this runs on means something is very wrong — the token becomes a
59/// value nothing can present, so the gate refuses everything rather than
60/// accepting anything. A closed door is the right way to fail.
61pub fn launch_token() -> &'static str {
62 static TOKEN: OnceLock<String> = OnceLock::new();
63 TOKEN.get_or_init(|| {
64 let mut bytes = [0u8; 24];
65 match getrandom::fill(&mut bytes) {
66 Ok(()) => bytes.iter().map(|b| format!("{b:02x}")).collect(),
67 Err(_) => String::new(),
68 }
69 })
70}
71
72/// The URL the host points the WebView at.
73///
74/// `base` is [`crate::EmbeddedServer::base_url`].
75pub struct LaunchToken;
76
77impl LaunchToken {
78 /// The one URL that will be let in without a cookie.
79 pub fn launch_url(base: &str) -> String {
80 format!(
81 "{}/?{LAUNCH_PARAM}={}",
82 base.trim_end_matches('/'),
83 launch_token()
84 )
85 }
86}
87
88/// Refuse anything that did not come from this launch.
89///
90/// Wired by the native host as the outermost layer:
91///
92/// ```ignore
93/// router.layer(axum::middleware::from_fn(rahti_native::gate))
94/// ```
95///
96/// Wired *only* when `security.loopbackToken` is on, which is why there is no
97/// runtime switch here — a project that turned it off does not have this layer
98/// in its router at all, rather than having a layer that decides it does not
99/// apply.
100pub async fn gate(request: Request, next: Next) -> Response {
101 let token = launch_token();
102
103 // An empty token means the launch had no randomness. Nothing can match it,
104 // which is deliberate.
105 if token.is_empty() {
106 return refuse();
107 }
108
109 if cookie_matches(&request, token) {
110 return next.run(request).await;
111 }
112
113 if let Some(clean) = query_matches(&request, token) {
114 return admit(&clean, token);
115 }
116
117 refuse()
118}
119
120/// Whether the request carries the launch cookie.
121fn cookie_matches(request: &Request, token: &str) -> bool {
122 let Some(header) = request.headers().get(header::COOKIE) else {
123 return false;
124 };
125 let Ok(header) = header.to_str() else {
126 return false;
127 };
128
129 header
130 .split(';')
131 .filter_map(|pair| pair.split_once('='))
132 .any(|(name, value)| name.trim() == LAUNCH_PARAM && constant_time_eq(value.trim(), token))
133}
134
135/// Whether this is the launch URL, and what to redirect to if it is.
136///
137/// Only a `GET` qualifies. The launch URL is a navigation, and accepting a
138/// token in the query string of a `POST` would put it in reach of any page
139/// that could get the application to submit a form.
140fn query_matches(request: &Request, token: &str) -> Option<String> {
141 if request.method() != axum::http::Method::GET {
142 return None;
143 }
144
145 let uri = request.uri();
146 let query = uri.query()?;
147
148 let mut carried = false;
149 let mut rest: Vec<&str> = Vec::new();
150 for pair in query.split('&') {
151 match pair.split_once('=') {
152 Some((LAUNCH_PARAM, value)) => carried = constant_time_eq(value, token),
153 _ => rest.push(pair),
154 }
155 }
156 if !carried {
157 return None;
158 }
159
160 // Back to the URL without the token, so it does not sit in the address bar,
161 // in `document.location`, or in a `Referer` header on the next request.
162 let path = uri.path();
163 Some(if rest.is_empty() {
164 path.to_string()
165 } else {
166 format!("{path}?{}", rest.join("&"))
167 })
168}
169
170/// Set the cookie and send the WebView to the clean URL.
171fn admit(clean: &str, token: &str) -> Response {
172 // `HttpOnly`, because no page has any reason to read it and an XSS that
173 // could would have stolen the one credential this layer rests on.
174 // `SameSite=Strict`, because every request the application makes is to its
175 // own origin, so nothing legitimate is lost and a cross-site navigation
176 // into the port arrives without it.
177 // No `Secure`: the loopback origin is `http:`, and a `Secure` cookie on it
178 // is a cookie the browser will not send.
179 let cookie = format!("{LAUNCH_PARAM}={token}; Path=/; HttpOnly; SameSite=Strict");
180
181 let mut response = (StatusCode::SEE_OTHER, "").into_response();
182 let headers = response.headers_mut();
183 if let Ok(value) = HeaderValue::from_str(&cookie) {
184 headers.insert(header::SET_COOKIE, value);
185 }
186 if let Ok(value) = HeaderValue::from_str(clean) {
187 headers.insert(header::LOCATION, value);
188 }
189 response
190}
191
192/// 403 and nothing informative.
193///
194/// No body describing the scheme, no header naming the application: a process
195/// scanning loopback ports learns that something refused it.
196fn refuse() -> Response {
197 StatusCode::FORBIDDEN.into_response()
198}
199
200/// Compare without leaking where two values first differ.
201///
202/// The token is a secret being compared against attacker-supplied input, which
203/// is the case `==` on a `str` is wrong for.
204fn constant_time_eq(a: &str, b: &str) -> bool {
205 let (a, b) = (a.as_bytes(), b.as_bytes());
206 if a.len() != b.len() {
207 return false;
208 }
209 a.iter()
210 .zip(b)
211 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
212 .eq(&0)
213}