From 824ca03ad499c4c7ff4cd5a9593ca7098e3fd3ad Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Fri, 19 Jun 2026 20:42:22 +0200 Subject: [PATCH] fix(security): allow SvelteKit inline bootstrap via per-script CSP hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global `script-src 'self'` CSP header blocked every 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