use std::sync::Arc; use api_types::{ ADMIN_SETTINGS, ADMIN_USERS, AUTH_LOGIN, AUTH_LOGOUT, AUTH_ME, AUTH_SETUP, FILES, SHARE, SHARES, }; use axum::Router; use axum::http::HeaderValue; use axum::routing::{delete, get, post, put}; use tower_http::set_header::SetResponseHeaderLayer; use crate::error::AppState; /// Content-Security-Policy tuned to the built Trunk frontend. /// /// * `script-src 'unsafe-inline'` — Trunk emits one inline bootstrap module /// in index.html; every real JS file also carries an SRI integrity hash. /// * `'wasm-unsafe-eval'` — compiling the same-origin WASM module. /// * `style-src 'unsafe-inline'` — the context menu sets an inline `style=`. /// * Everything else locked to the same origin; frames/plugins banned. 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';"; /// Content-Security-Policy for served *user files* that the browser would /// treat as a scripting document (HTML, SVG, XML). Lets such a file render as /// a real page — the "share an HTML page" flow — without letting it act as the /// app. /// /// The security hinges on one omission: `allow-scripts` **without** /// `allow-same-origin`. That forces the document into a unique opaque origin, /// so its JavaScript cannot read the session cookie's origin, cannot touch /// `localStorage`, and cannot call `/api` as the viewer (the server sends no /// CORS headers, so every cross-origin read fails). Never add /// `allow-same-origin` here. /// /// Also deliberately absent: /// * `allow-top-navigation` — a shared page cannot silently redirect the /// viewer elsewhere. `-by-user-activation` still lets links work on click. /// * `allow-popups-to-escape-sandbox` — a popup would drop the sandbox. /// /// `connect-src *` is deliberate: a shared page may call third-party APIs. /// The trade-off is that it can also beacon (report that the link was opened, /// and anything the page itself contains). It cannot exfiltrate anything of /// the viewer's — the opaque origin means it has no session and no CORS read /// access to this server. 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;"; /// True for MIME types the browser parses as a scripting document. These are /// the responses that need [`FILE_CSP`]; everything else keeps the app policy. /// /// This reads the *declared* type — the same value that becomes the /// `Content-Type` header — on purpose. If the sandbox decision and the render /// decision ever read different inputs, a file can be rendered as a scripting /// document without being sandboxed. pub(crate) fn is_scriptable_mime(mime: &str) -> bool { let base = mime.split(';').next().unwrap_or("").trim(); matches!( base, "text/html" | "application/xhtml+xml" | "image/svg+xml" | "text/xml" | "application/xml" ) || base.ends_with("+xml") } mod admin; mod auth; mod common; mod files; mod shares; mod spa; pub fn router(state: Arc) -> Router { // Route patterns: the server side of the shared endpoint strings in // `api_types` (axum copies them into the route table on insert). let files_root = format!("{FILES}/{{root_id}}"); let files_item = format!("{FILES}/{{root_id}}/{{*path}}"); let shares_id = format!("{SHARES}/{{id}}"); let share_token = format!("{SHARE}/{{token}}"); let admin_user_id = format!("{ADMIN_USERS}/{{id}}"); Router::new() .route(AUTH_LOGIN, post(auth::login)) .route(AUTH_LOGOUT, post(auth::logout)) .route(AUTH_ME, get(auth::me)) .route(AUTH_SETUP, post(auth::setup)) .route(&files_root, get(files::list_root)) .route(&files_item, get(files::file_get)) .route(&files_item, put(files::file_put)) .route(&files_root, post(files::dispatch_root)) .route(&files_item, post(files::dispatch)) .route(&files_item, delete(files::delete)) .route(SHARES, get(shares::list)) .route(SHARES, post(shares::create)) .route(&shares_id, delete(shares::delete)) .route(&share_token, get(shares::resolve)) .route(ADMIN_USERS, get(admin::list_users)) .route(ADMIN_USERS, post(admin::create_user)) .route(&admin_user_id, put(admin::update_user)) .route(&admin_user_id, delete(admin::delete_user)) .route(ADMIN_SETTINGS, get(admin::get_settings)) .route(ADMIN_SETTINGS, put(admin::update_settings)) .fallback(spa::fallback) .with_state(state) // Hard security headers on every response (API and static alike). // CSP is `if_not_present` so that file responses can substitute the // sandboxed [`FILE_CSP`]; everything else gets the app policy. .layer(SetResponseHeaderLayer::if_not_present( "content-security-policy".parse().unwrap(), HeaderValue::from_static(CSP), )) .layer(SetResponseHeaderLayer::overriding( "x-content-type-options".parse().unwrap(), HeaderValue::from_static("nosniff"), )) .layer(SetResponseHeaderLayer::overriding( "x-frame-options".parse().unwrap(), HeaderValue::from_static("DENY"), )) .layer(SetResponseHeaderLayer::overriding( "referrer-policy".parse().unwrap(), HeaderValue::from_static("no-referrer"), )) }