diff --git a/src/interfaces/web/mod.rs b/src/interfaces/web/mod.rs index 37a27cb0..d16c09f3 100644 --- a/src/interfaces/web/mod.rs +++ b/src/interfaces/web/mod.rs @@ -3,12 +3,41 @@ use crate::common::di::AppState; use axum::Router; use axum::http::header::{CACHE_CONTROL, HeaderValue}; use axum::routing::get_service; -use std::path::Path; +use base64::Engine as _; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tower_http::compression::CompressionLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::set_header::SetResponseHeaderLayer; +/// Resolve the directory the SPA is actually served from. +/// +/// Release builds prefer the Vite output next to the configured static path — +/// `static-dist/`, or `static/` under `PROFILE=dev` — falling back to the +/// configured path when that build dir is absent. Debug builds always use the +/// configured path. Shared with the CSP layer in `main.rs` so the inline-script +/// hashes are computed from exactly the bytes that get served. +pub fn resolve_static_path(config: &AppConfig) -> PathBuf { + // `PROFILE=dev` (the `just front-dev`/legacy path) serves the unbuilt source + // dir; normal release serves the Vite output in `static-dist/`. + let is_dev = std::env::var("PROFILE").is_ok_and(|profile| profile == "dev"); + let assets_dir = if is_dev { "static" } else { "static-dist" }; + + if cfg!(not(debug_assertions)) { + let dist = config + .static_path + .parent() + .unwrap_or(Path::new(".")) + .join(assets_dir); + if dist.exists() { + return dist; + } + } + config.static_path.clone() +} + /// Serves the SvelteKit single-page app. /// /// The frontend is built by Vite into `static-dist/` (repo root). Real files are @@ -21,26 +50,8 @@ use tower_http::set_header::SetResponseHeaderLayer; /// can't leave a stale app pinned in browsers. pub fn create_web_routes() -> Router> { let config = AppConfig::from_env(); - - // `PROFILE=dev` (the `just front-dev`/legacy path) serves the unbuilt source - // dir; normal release serves the Vite output in `static-dist/`. let is_dev = std::env::var("PROFILE").is_ok_and(|profile| profile == "dev"); - let assets_dir = if is_dev { "static" } else { "static-dist" }; - - let static_path = if cfg!(not(debug_assertions)) { - let dist = config - .static_path - .parent() - .unwrap_or(Path::new(".")) - .join(assets_dir); - if dist.exists() { - dist - } else { - config.static_path.clone() - } - } else { - config.static_path.clone() - }; + let static_path = resolve_static_path(&config); // SPA fallback: serve the file if it exists, else the app shell. let spa = ServeDir::new(&static_path).fallback(ServeFile::new(static_path.join("index.html"))); @@ -70,3 +81,192 @@ pub fn create_web_routes() -> Router> { HeaderValue::from_static(shell_cache), )) } + +/// Build the `content-security-policy` header value served on every response. +/// +/// `script-src` stays strict — `'self'` with **no** `'unsafe-inline'` — and +/// additionally lists a `'sha256-…'` source for each inline ``, so +/// each shell is read verbatim and that slice hashed. Scripts carrying a `src` +/// attribute are external (already allowed by `'self'`) and skipped. Only the +/// directory root is scanned — the SPA is client-rendered (SSR/prerender off), +/// so the only inline-script shell is `index.html`; any legacy pages sit beside +/// it. Returns a deduplicated, sorted list; empty when the dir is unreadable +/// (e.g. a Vite dev server serving HTML on its own port instead). +fn inline_script_csp_hashes(static_path: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(static_path) else { + return Vec::new(); + }; + + let mut hashes = BTreeSet::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("html") { + continue; + } + let Ok(html) = std::fs::read_to_string(&path) else { + continue; + }; + for script in inline_scripts(&html) { + hashes.insert(csp_hash(script)); + } + } + hashes.into_iter().collect() +} + +/// The CSP `'sha256-'` source expression for one inline script body. +fn csp_hash(script: &str) -> String { + let digest = Sha256::digest(script.as_bytes()); + let encoded = base64::engine::general_purpose::STANDARD.encode(digest); + format!("'sha256-{encoded}'") +} + +/// Text content of every inline `") else { + break; + }; + let content_end = content_start + close_rel; + if !opening_tag_has_src(open_tag) { + scripts.push(&html[content_start..content_end]); + } + cursor = content_end + "".len(); + } + scripts +} + +/// Whether a `"; + assert_eq!(inline_scripts(html), vec!["\n alert(1);\n"]); + } + + #[test] + fn skips_external_src_scripts() { + let html = r#""#; + assert_eq!(inline_scripts(html), vec!["boot();"]); + } + + #[test] + fn keeps_inline_module_skips_module_with_src() { + let html = + r#""#; + assert_eq!(inline_scripts(html), vec!["go();"]); + } + + #[test] + fn case_insensitive_tag_matching() { + let html = ""; + assert_eq!(inline_scripts(html), vec!["run();"]); + } + + #[test] + fn empty_inline_script_hash_matches_known_sha256_vector() { + // SHA-256 of the empty string, base64 — the canonical empty digest. + assert_eq!( + csp_hash(""), + "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" + ); + } + + #[test] + fn identical_scripts_produce_one_deduplicated_hash() { + let html = ""; + let mut set = BTreeSet::new(); + for s in inline_scripts(html) { + set.insert(csp_hash(s)); + } + assert_eq!(set.len(), 1); + } + + #[test] + fn distinct_scripts_produce_distinct_hashes() { + assert_ne!(csp_hash("a()"), csp_hash("b()")); + } +} diff --git a/src/main.rs b/src/main.rs index 6f462fbb..79270845 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,7 +49,8 @@ use oxicloud::interfaces; use common::di::AppServiceFactory; use infrastructure::db::create_database_pools; use interfaces::{ - create_api_routes, create_health_routes, create_public_api_routes, web::create_web_routes, + create_api_routes, create_health_routes, create_public_api_routes, + web::{content_security_policy, create_web_routes}, }; fn parse_addr(host: &str, port: u16) -> Result { @@ -789,28 +790,14 @@ async fn main() -> Result<(), Box> { app = app .layer(SetResponseHeaderLayer::overriding( HeaderName::from_static("content-security-policy"), - // Note: 'unsafe-inline' is required for style-src because the - // frontend JavaScript dynamically sets inline styles (e.g., - // element.style.display = 'none'). This is a common pattern - // for UI state management and cannot be easily migrated to - // external CSS classes without significant refactoring. - // frame-src: '*' only matches network schemes, so 'blob:' must be - // listed explicitly for inline PDF/document viewers. - // media-src: needed for blob: video/audio playback. - HeaderValue::from_static( - "default-src 'self'; \ - script-src 'self'; \ - worker-src 'self'; \ - style-src 'self' 'unsafe-inline'; \ - img-src 'self' data: blob: https:; \ - media-src 'self' blob:; \ - connect-src 'self'; \ - font-src 'self' data:; \ - frame-src * blob:; \ - frame-ancestors 'none'; \ - base-uri 'self'; \ - form-action 'self'", - ), + // Built at startup: script-src is 'self' plus a SHA-256 hash for + // every inline