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