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        if !headers.contains_key(header::CONTENT_SECURITY_POLICY) {
64            if let Ok(value) = HeaderValue::from_str(policy) {
65                headers.insert(header::CONTENT_SECURITY_POLICY, value);
66            }
67        }
68    }
69
70    // A stylesheet that a browser decided was HTML is a stylesheet that can
71    // carry a script. The server sends a type for everything it serves, so
72    // sniffing can only ever disagree with it.
73    headers
74        .entry(header::X_CONTENT_TYPE_OPTIONS)
75        .or_insert(HeaderValue::from_static("nosniff"));
76
77    // The loopback URL carries the port, and for one request it carries the
78    // launch token. Neither belongs in a `Referer` sent anywhere else.
79    headers
80        .entry(header::REFERRER_POLICY)
81        .or_insert(HeaderValue::from_static("no-referrer"));
82
83    response
84}
85
86/// Put a Rahti router behind the native security layers.
87///
88/// One call rather than two `.layer(...)` lines in every generated shell, for
89/// two reasons. The shell then needs no `axum` dependency of its own — it
90/// never names a type from it — and the *order* of the two layers is decided
91/// here, where it can be tested, rather than in generated code where getting
92/// it backwards would be invisible.
93///
94/// The order: `axum` applies the last `.layer` outermost, so the gate is added
95/// second and runs first. A request that did not come from this launch is
96/// refused before it reaches the auth guard, the CSRF layer, a handler, or the
97/// static file service.
98///
99/// `loopback_token` is `security.loopbackToken`. When it is off the layer is
100/// not in the router at all, rather than present and deciding it does not
101/// apply.
102pub fn secure(router: axum::Router, loopback_token: bool) -> axum::Router {
103    let router = router.layer(axum::middleware::from_fn(security_headers));
104    if loopback_token {
105        return router.layer(axum::middleware::from_fn(crate::gate::gate));
106    }
107    router
108}