mod.rs
⎇
Raw
1use std::sync::Arc;
2
3use api_types::{
4 ADMIN_SETTINGS, ADMIN_SHARES, ADMIN_USERS, AUTH_APP_PASSWORDS, AUTH_LOGIN, AUTH_LOGOUT,
5 AUTH_ME, AUTH_MODE, AUTH_PASSKEY_LOGIN, AUTH_PASSKEYS, AUTH_PASSKEYS_REGISTER, AUTH_PASSWORD,
6 AUTH_SETUP, DAV, DAV_SHARE, FILES, FINISH_SUFFIX, SEARCH, SHARE, SHARE_UNLOCK_SUFFIX, SHARES,
7};
8use axum::Router;
9use axum::http::HeaderValue;
10use axum::routing::{any, delete, get, post, put};
11use tower_http::set_header::SetResponseHeaderLayer;
12
13use crate::error::AppState;
14
15/// Content-Security-Policy tuned to the built Trunk frontend.
16///
17/// * `script-src 'unsafe-inline'` — Trunk emits one inline bootstrap module
18/// in index.html; every real JS file also carries an SRI integrity hash.
19/// * `'wasm-unsafe-eval'` — compiling the same-origin WASM module.
20/// * `style-src 'unsafe-inline'` — the context menu sets an inline `style=`.
21/// * Everything else locked to the same origin; frames/plugins banned.
22const 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';";
23
24/// Content-Security-Policy for served *user files* that the browser would
25/// treat as a scripting document (HTML, SVG, XML). Lets such a file render as
26/// a real page — the "share an HTML page" flow — without letting it act as the
27/// app.
28///
29/// The security hinges on one omission: `allow-scripts` **without**
30/// `allow-same-origin`. That forces the document into a unique opaque origin,
31/// so its JavaScript cannot read the session cookie's origin, cannot touch
32/// `localStorage`, and cannot call `/api` as the viewer (the server sends no
33/// CORS headers, so every cross-origin read fails). Never add
34/// `allow-same-origin` here.
35///
36/// Also deliberately absent:
37/// * `allow-top-navigation` — a shared page cannot silently redirect the
38/// viewer elsewhere. `-by-user-activation` still lets links work on click.
39/// * `allow-popups-to-escape-sandbox` — a popup would drop the sandbox.
40///
41/// `connect-src *` is deliberate: a shared page may call third-party APIs.
42/// The trade-off is that it can also beacon (report that the link was opened,
43/// and anything the page itself contains). It cannot exfiltrate anything of
44/// the viewer's — the opaque origin means it has no session and no CORS read
45/// access to this server.
46pub(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;";
47
48/// Apply [`FILE_CSP`] to a response that declares a scriptable type.
49///
50/// The router's CSP layer is `if_not_present`, so without this such a response
51/// keeps the *app* policy: same origin, scripts allowed. A `GET` of a shared
52/// HTML file is a top-level navigation, which carries the session cookie under
53/// `SameSite=Lax`, so the page would then run as the viewer against `/api`.
54pub(crate) fn sandbox_scriptable<B>(resp: &mut axum::http::Response<B>) {
55 let scriptable = resp
56 .headers()
57 .get(axum::http::header::CONTENT_TYPE)
58 .and_then(|v| v.to_str().ok())
59 .is_some_and(is_scriptable_mime);
60 if scriptable {
61 resp.headers_mut().insert(
62 "content-security-policy",
63 HeaderValue::from_static(FILE_CSP),
64 );
65 }
66}
67
68/// Content-Security-Policy for inline (preview) files that are *not*
69/// scripting documents (PDF, media, …): the preview modal embeds them in a
70/// same-origin `<iframe>`. Scriptable files never get this — see [`FILE_CSP`].
71pub(crate) const INLINE_CSP: &str = "frame-ancestors 'self';";
72
73/// True for MIME types the browser parses as a scripting document. These are
74/// the responses that need [`FILE_CSP`]; everything else keeps the app policy.
75///
76/// This reads the *declared* type — the same value that becomes the
77/// `Content-Type` header — on purpose. If the sandbox decision and the render
78/// decision ever read different inputs, a file can be rendered as a scripting
79/// document without being sandboxed.
80pub(crate) fn is_scriptable_mime(mime: &str) -> bool {
81 let base = mime.split(';').next().unwrap_or("").trim();
82 matches!(
83 base,
84 "text/html" | "application/xhtml+xml" | "image/svg+xml" | "text/xml" | "application/xml"
85 ) || base.ends_with("+xml")
86}
87
88mod admin;
89mod app_passwords;
90mod auth;
91pub(crate) mod common;
92mod dav;
93mod files;
94mod passkeys;
95mod search;
96mod shares;
97mod spa;
98
99pub fn router(state: Arc<AppState>) -> Router {
100 // Route patterns: the server side of the shared endpoint strings in
101 // `api_types` (axum copies them into the route table on insert).
102 let files_root = format!("{FILES}/{{root_id}}");
103 let files_item = format!("{FILES}/{{root_id}}/{{*path}}");
104 let shares_id = format!("{SHARES}/{{id}}");
105 let share_token = format!("{SHARE}/{{token}}");
106 let share_unlock = format!("{SHARE}/{{token}}{SHARE_UNLOCK_SUFFIX}");
107 let admin_user_id = format!("{ADMIN_USERS}/{{id}}");
108 let passkey_id = format!("{AUTH_PASSKEYS}/{{id}}");
109 let app_password_id = format!("{AUTH_APP_PASSWORDS}/{{id}}");
110 let passkey_register_finish = format!("{AUTH_PASSKEYS_REGISTER}{FINISH_SUFFIX}");
111 let passkey_login_finish = format!("{AUTH_PASSKEY_LOGIN}{FINISH_SUFFIX}");
112 let admin_share_id = format!("{ADMIN_SHARES}/{{id}}");
113 // A wildcard needs something to capture, so `/dav/` gets its own pattern:
114 // mount clients ask for it with the trailing slash, which matches neither
115 // the bare path nor `{*path}`.
116 let dav_root = format!("{DAV}/");
117 let dav_item = format!("{DAV}/{{*path}}");
118 let dav_share = format!("{DAV_SHARE}/{{*path}}");
119
120 Router::new()
121 .route(AUTH_LOGIN, post(auth::login))
122 .route(AUTH_LOGOUT, post(auth::logout))
123 .route(AUTH_ME, get(auth::me).put(auth::update_profile))
124 .route(AUTH_SETUP, post(auth::setup))
125 .route(
126 AUTH_PASSWORD,
127 post(passkeys::change_password).delete(passkeys::delete_password),
128 )
129 .route(AUTH_MODE, put(passkeys::set_mode))
130 // axum matches a literal segment before a parameter, so `/register`
131 // and `{id}` can both live under this path.
132 .route(AUTH_PASSKEYS, get(passkeys::list))
133 .route(AUTH_PASSKEYS_REGISTER, post(passkeys::register_begin))
134 .route(&passkey_register_finish, post(passkeys::register_finish))
135 .route(&passkey_id, delete(passkeys::delete))
136 .route(AUTH_PASSKEY_LOGIN, post(passkeys::login_begin))
137 .route(&passkey_login_finish, post(passkeys::login_finish))
138 .route(
139 AUTH_APP_PASSWORDS,
140 get(app_passwords::list).post(app_passwords::create),
141 )
142 .route(&app_password_id, delete(app_passwords::delete))
143 .route(SEARCH, get(search::search))
144 .route(
145 &files_root,
146 get(files::file_get)
147 .put(files::file_put)
148 .post(files::dispatch),
149 )
150 .route(
151 &files_item,
152 get(files::file_get)
153 .put(files::file_put)
154 .post(files::dispatch)
155 .delete(files::delete),
156 )
157 .route(SHARES, get(shares::list))
158 .route(SHARES, post(shares::create))
159 .route(&shares_id, delete(shares::delete))
160 .route(&share_token, get(shares::resolve))
161 .route(&share_unlock, post(shares::unlock))
162 .route(ADMIN_USERS, get(admin::list_users))
163 .route(ADMIN_USERS, post(admin::create_user))
164 .route(&admin_user_id, put(admin::update_user))
165 .route(&admin_user_id, delete(admin::delete_user))
166 .route(ADMIN_SHARES, get(admin::list_shares))
167 .route(&admin_share_id, delete(admin::delete_share))
168 .route(ADMIN_SETTINGS, get(admin::get_settings))
169 .route(ADMIN_SETTINGS, put(admin::update_settings))
170 // `any`, not a method filter: WebDAV's verbs (PROPFIND, MKCOL, MOVE, …)
171 // are not in axum's `MethodFilter`, and a method router's fallback
172 // takes every one of them.
173 .route(DAV, any(dav::user))
174 .route(&dav_root, any(dav::user))
175 .route(&dav_item, any(dav::user))
176 .route(&dav_share, any(dav::share))
177 .fallback(spa::fallback)
178 // The editor save body is checked against `MAX_TEXT_BYTES` in the
179 // handler; axum's default limit is the same 2 MiB, which would win
180 // with a plain 413 instead of the localized error.
181 .layer(axum::extract::DefaultBodyLimit::max(
182 files::MAX_TEXT_BYTES as usize + 64 * 1024,
183 ))
184 .with_state(state)
185 // Hard security headers on every response (API and static alike).
186 // CSP/XFO are `if_not_present` so file responses can substitute
187 // [`FILE_CSP`] or the same-origin-framable [`INLINE_CSP`]; everything
188 // else gets the app policy.
189 .layer(SetResponseHeaderLayer::if_not_present(
190 "content-security-policy".parse().unwrap(),
191 HeaderValue::from_static(CSP),
192 ))
193 .layer(SetResponseHeaderLayer::overriding(
194 "x-content-type-options".parse().unwrap(),
195 HeaderValue::from_static("nosniff"),
196 ))
197 .layer(SetResponseHeaderLayer::if_not_present(
198 "x-frame-options".parse().unwrap(),
199 HeaderValue::from_static("DENY"),
200 ))
201 .layer(SetResponseHeaderLayer::overriding(
202 "referrer-policy".parse().unwrap(),
203 HeaderValue::from_static("no-referrer"),
204 ))
205}
206