Skip to main content

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}