Skip to main content

rahti_native/
headers.rs

1//! The response headers a packaged application serves.
2//!
3//! ## Why the policy is not in `tauri.conf.json`
4//!
5//! Tauri's `app.security.csp` applies to the documents Tauri itself serves
6//! through its asset protocol. A packaged Rahti application does not use it:
7//! every document comes from the embedded HTTP server, on a `http://127.0.0.1`
8//! origin, and the only Content-Security-Policy a browser will apply to those
9//! is one that arrives in *their* response headers.
10//!
11//! So it is set here, by the server that serves them. The value in
12//! `rahti.native.json` is the one that reaches the page; the value in the
13//! generated `tauri.conf.json` covers the shell's own placeholder document and
14//! is the same string so that neither can be mistaken for the other.
15//!
16//! ## Why it matters more here than on the web
17//!
18//! A cross-site scripting bug in a web page steals a session. The same bug in
19//! a native shell reaches the native command bridge as well. The policy is the
20//! layer that stops injected markup from loading an attacker's script at all,
21//! and it is worth being strict about in exactly the place where being wrong
22//! costs the most.
23
24use std::sync::OnceLock;
25
26use axum::extract::Request;
27use axum::http::{HeaderValue, header};
28use axum::middleware::Next;
29use axum::response::Response;
30
31static POLICY: OnceLock<String> = OnceLock::new();
32
33/// Install the policy the [`security_headers`] layer will send.
34///
35/// Called once by the native host, before the router is built. A second call
36/// is ignored: the policy belongs to the launch, and a layer whose policy
37/// could change mid-run is a layer whose behaviour cannot be reasoned about.
38pub fn install_csp(policy: &str) {
39    let _ = POLICY.set(policy.to_string());
40}
41
42/// The installed policy, if there is one.
43pub fn csp() -> Option<&'static str> {
44    POLICY.get().map(String::as_str)
45}
46
47/// Add the security headers to every response.
48///
49/// Wired by the native host, outside the application's own layers:
50///
51/// ```ignore
52/// router.layer(axum::middleware::from_fn(rahti_native::security_headers))
53/// ```
54///
55/// Existing values are left alone. A page that set its own policy meant it,
56/// and a layer that overwrote it would be a layer that quietly widened
57/// somebody's deliberate narrowing.
58pub async fn security_headers(request: Request, next: Next) -> Response {
59    let mut response = next.run(request).await;
60    let headers = response.headers_mut();
61
62    if let Some(policy) = csp()
63        && !headers.contains_key(header::CONTENT_SECURITY_POLICY)
64        && let Ok(value) = HeaderValue::from_str(policy)
65    {
66        headers.insert(header::CONTENT_SECURITY_POLICY, value);
67    }
68
69    // A stylesheet that a browser decided was HTML is a stylesheet that can
70    // carry a script. The server sends a type for everything it serves, so
71    // sniffing can only ever disagree with it.
72    headers
73        .entry(header::X_CONTENT_TYPE_OPTIONS)
74        .or_insert(HeaderValue::from_static("nosniff"));
75
76    // The loopback URL carries the port, and for one request it carries the
77    // launch token. Neither belongs in a `Referer` sent anywhere else.
78    headers
79        .entry(header::REFERRER_POLICY)
80        .or_insert(HeaderValue::from_static("no-referrer"));
81
82    response
83}
84
85/// Put a Rahti router behind the native security layers.
86///
87/// One call rather than two `.layer(...)` lines in every generated shell, for
88/// two reasons. The shell then needs no `axum` dependency of its own — it
89/// never names a type from it — and the *order* of the two layers is decided
90/// here, where it can be tested, rather than in generated code where getting
91/// it backwards would be invisible.
92///
93/// The order: `axum` applies the last `.layer` outermost, so the gate is added
94/// second and runs first. A request that did not come from this launch is
95/// refused before it reaches the auth guard, the CSRF layer, a handler, or the
96/// static file service.
97///
98/// `loopback_token` is `security.loopbackToken`. When it is off the layer is
99/// not in the router at all, rather than present and deciding it does not
100/// apply.
101pub fn secure(router: axum::Router, loopback_token: bool) -> axum::Router {
102    let router = router.layer(axum::middleware::from_fn(security_headers));
103    if loopback_token {
104        return router.layer(axum::middleware::from_fn(crate::gate::gate));
105    }
106    router
107}