mod.rs
⎇
Raw
1use std::sync::Arc;
2
3use api_types::{
4 ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SHARE, SHARES,
5};
6use axum::Router;
7use axum::http::HeaderValue;
8use axum::routing::{delete, get, post, put};
9use tower_http::set_header::SetResponseHeaderLayer;
10
11use crate::error::AppState;
12
13/// Content-Security-Policy tuned to the built Trunk frontend.
14///
15/// * `script-src 'unsafe-inline'` — Trunk emits one inline bootstrap module
16/// in index.html; every real JS file also carries an SRI integrity hash.
17/// * `'wasm-unsafe-eval'` — compiling the same-origin WASM module.
18/// * `style-src 'unsafe-inline'` — the context menu sets an inline `style=`.
19/// * Everything else locked to the same origin; frames/plugins banned.
20const CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
21
22/// Content-Security-Policy for served *user files* that the browser would
23/// treat as a scripting document (HTML, SVG, XML). Lets such a file render as
24/// a real page — the "share an HTML page" flow — without letting it act as the
25/// app.
26///
27/// The security hinges on one omission: `allow-scripts` **without**
28/// `allow-same-origin`. That forces the document into a unique opaque origin,
29/// so its JavaScript cannot read the session cookie's origin, cannot touch
30/// `localStorage`, and cannot call `/api` as the viewer (the server sends no
31/// CORS headers, so every cross-origin read fails). Never add
32/// `allow-same-origin` here.
33///
34/// Also deliberately absent:
35/// * `allow-top-navigation` — a shared page cannot silently redirect the
36/// viewer elsewhere. `-by-user-activation` still lets links work on click.
37/// * `allow-popups-to-escape-sandbox` — a popup would drop the sandbox.
38///
39/// `connect-src *` is deliberate: a shared page may call third-party APIs.
40/// The trade-off is that it can also beacon (report that the link was opened,
41/// and anything the page itself contains). It cannot exfiltrate anything of
42/// the viewer's — the opaque origin means it has no session and no CORS read
43/// access to this server.
44pub(crate) const FILE_CSP: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src *; object-src 'none'; frame-ancestors 'none'; sandbox allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-top-navigation-by-user-activation;";
45
46/// Content-Security-Policy for inline (preview) files that are *not*
47/// scripting documents (PDF, media, …): the preview modal embeds them in a
48/// same-origin `<iframe>`. Scriptable files never get this — see [`FILE_CSP`].
49pub(crate) const INLINE_CSP: &str = "frame-ancestors 'self';";
50
51/// True for MIME types the browser parses as a scripting document. These are
52/// the responses that need [`FILE_CSP`]; everything else keeps the app policy.
53///
54/// This reads the *declared* type — the same value that becomes the
55/// `Content-Type` header — on purpose. If the sandbox decision and the render
56/// decision ever read different inputs, a file can be rendered as a scripting
57/// document without being sandboxed.
58pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
59 let base = mime.split(';').next().unwrap_or("").trim();
60 matches!(
61 base,
62 "text/html" | "application/xhtml+xml" | "image/svg+xml" | "text/xml" | "application/xml"
63 ) || base.ends_with("+xml")
64}
65
66mod admin;
67mod auth;
68mod common;
69mod files;
70mod shares;
71mod spa;
72
73pub fn router(state: Arc<AppState>) -> Router {
74 // Route patterns: the server side of the shared endpoint strings in
75 // `api_types` (axum copies them into the route table on insert).
76 let files_root = format!("{FILES}/{{root_id}}");
77 let files_item = format!("{FILES}/{{root_id}}/{{*path}}");
78 let shares_id = format!("{SHARES}/{{id}}");
79 let share_token = format!("{SHARE}/{{token}}");
80 let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
81
82 Router::new()
83 .route(AUTH_LOGIN, post(auth::login))
84 .route(AUTH_LOGOUT, post(auth::logout))
85 .route(AUTH_ME, get(auth::me).put(auth::update_profile))
86 .route(AUTH_SETUP, post(auth::setup))
87 .route(&files_root, get(files::list_root).put(files::file_put_root))
88 .route(&files_item, get(files::file_get))
89 .route(&files_item, put(files::file_put))
90 .route(&files_root, post(files::dispatch_root))
91 .route(&files_item, post(files::dispatch))
92 .route(&files_item, delete(files::delete))
93 .route(SHARES, get(shares::list))
94 .route(SHARES, post(shares::create))
95 .route(&shares_id, delete(shares::delete))
96 .route(&share_token, get(shares::resolve))
97 .route(ADMIN_USERS, get(admin::list_users))
98 .route(ADMIN_USERS, post(admin::create_user))
99 .route(&admin_user_id, put(admin::update_user))
100 .route(&admin_user_id, delete(admin::delete_user))
101 .route(ADMIN_SETTINGS, get(admin::get_settings))
102 .route(ADMIN_SETTINGS, put(admin::update_settings))
103 .fallback(spa::fallback)
104 .with_state(state)
105 // Hard security headers on every response (API and static alike).
106 // CSP/XFO are `if_not_present` so file responses can substitute
107 // [`FILE_CSP`] or the same-origin-framable [`INLINE_CSP`]; everything
108 // else gets the app policy.
109 .layer(SetResponseHeaderLayer::if_not_present(
110 "content-security-policy".parse().unwrap(),
111 HeaderValue::from_static(CSP),
112 ))
113 .layer(SetResponseHeaderLayer::overriding(
114 "x-content-type-options".parse().unwrap(),
115 HeaderValue::from_static("nosniff"),
116 ))
117 .layer(SetResponseHeaderLayer::if_not_present(
118 "x-frame-options".parse().unwrap(),
119 HeaderValue::from_static("DENY"),
120 ))
121 .layer(SetResponseHeaderLayer::overriding(
122 "referrer-policy".parse().unwrap(),
123 HeaderValue::from_static("no-referrer"),
124 ))
125}
126