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