fix(security): allow SvelteKit inline bootstrap via per-script CSP hashes
The global `script-src 'self'` CSP header blocked every inline <script>, so the SvelteKit hydration bootstrap never ran and the SPA never mounted (blank page behind the splash spinner). The anti-FOUC theme init in app.html was blocked too. Instead of weakening the policy with 'unsafe-inline', the backend now builds `script-src` at startup from 'self' plus a SHA-256 hash of every inline <script> in the served HTML shells. The policy stays strict (no 'unsafe-inline' for scripts) and the hashes are recomputed from the built assets on each startup, so a frontend rebuild needs no header edit — even though SvelteKit's bootstrap hash changes every build. - web::content_security_policy builds the header; web::resolve_static_path is extracted so serving and hashing read the exact same bytes. - byte-exact inline-script extraction (skips src= externals), unit-tested against a known SHA-256 vector and extraction edge cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+220
-20
@@ -3,12 +3,41 @@ use crate::common::di::AppState;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::http::header::{CACHE_CONTROL, HeaderValue};
|
use axum::http::header::{CACHE_CONTROL, HeaderValue};
|
||||||
use axum::routing::get_service;
|
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 std::sync::Arc;
|
||||||
use tower_http::compression::CompressionLayer;
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::services::{ServeDir, ServeFile};
|
use tower_http::services::{ServeDir, ServeFile};
|
||||||
use tower_http::set_header::SetResponseHeaderLayer;
|
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.
|
/// Serves the SvelteKit single-page app.
|
||||||
///
|
///
|
||||||
/// The frontend is built by Vite into `static-dist/` (repo root). Real files are
|
/// 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.
|
/// can't leave a stale app pinned in browsers.
|
||||||
pub fn create_web_routes() -> Router<Arc<AppState>> {
|
pub fn create_web_routes() -> Router<Arc<AppState>> {
|
||||||
let config = AppConfig::from_env();
|
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 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 = resolve_static_path(&config);
|
||||||
|
|
||||||
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()
|
|
||||||
};
|
|
||||||
|
|
||||||
// SPA fallback: serve the file if it exists, else the app shell.
|
// 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")));
|
let spa = ServeDir::new(&static_path).fallback(ServeFile::new(static_path.join("index.html")));
|
||||||
@@ -70,3 +81,192 @@ pub fn create_web_routes() -> Router<Arc<AppState>> {
|
|||||||
HeaderValue::from_static(shell_cache),
|
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 `<script>` found in
|
||||||
|
/// the served HTML shells: the anti-FOUC theme init in `app.html` and
|
||||||
|
/// SvelteKit's hydration bootstrap. Without those hashes the browser blocks the
|
||||||
|
/// bootstrap and the SPA never mounts (a blank page behind the splash spinner).
|
||||||
|
/// Hashes are recomputed from the built assets on every startup, so a frontend
|
||||||
|
/// rebuild needs no edit here.
|
||||||
|
///
|
||||||
|
/// Other directives:
|
||||||
|
/// - `style-src` keeps `'unsafe-inline'` because the frontend sets inline styles
|
||||||
|
/// (`element.style.*`) for UI state — impractical to migrate to classes.
|
||||||
|
/// - `frame-src` lists `blob:` explicitly (`*` only matches network schemes) for
|
||||||
|
/// inline PDF/document viewers; `media-src` lists `blob:` for blob video/audio.
|
||||||
|
pub fn content_security_policy(config: &AppConfig) -> String {
|
||||||
|
let static_path = resolve_static_path(config);
|
||||||
|
let hashes = inline_script_csp_hashes(&static_path);
|
||||||
|
if hashes.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
static_path = %static_path.display(),
|
||||||
|
"CSP: no inline <script> hashes computed — if the SPA shell ships \
|
||||||
|
inline scripts they will be blocked by script-src 'self'. Check the \
|
||||||
|
static asset path (OXICLOUD_STATIC_PATH)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut script_src = String::from("script-src 'self'");
|
||||||
|
for hash in &hashes {
|
||||||
|
script_src.push(' ');
|
||||||
|
script_src.push_str(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"default-src 'self'; \
|
||||||
|
{script_src}; \
|
||||||
|
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'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 CSP source expressions (`'sha256-…'`) for every inline `<script>` in
|
||||||
|
/// the root-level HTML shells under `static_path`.
|
||||||
|
///
|
||||||
|
/// The browser hashes the exact bytes between `<script …>` and `</script>`, 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<String> {
|
||||||
|
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-<base64>'` 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 `<script>` (no `src`) in `html`, returned as
|
||||||
|
/// byte-exact slices suitable for CSP hashing.
|
||||||
|
fn inline_scripts(html: &str) -> Vec<&str> {
|
||||||
|
let mut scripts = Vec::new();
|
||||||
|
let mut cursor = 0;
|
||||||
|
while let Some(rel) = find_ci(&html[cursor..], "<script") {
|
||||||
|
let tag_start = cursor + rel;
|
||||||
|
// End of the opening tag.
|
||||||
|
let Some(gt) = html[tag_start..].find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let open_tag = &html[tag_start..tag_start + gt + 1];
|
||||||
|
let content_start = tag_start + gt + 1;
|
||||||
|
// Matching close tag.
|
||||||
|
let Some(close_rel) = find_ci(&html[content_start..], "</script>") 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 + "</script>".len();
|
||||||
|
}
|
||||||
|
scripts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a `<script …>` opening tag carries a `src` attribute (i.e. it loads
|
||||||
|
/// an external file rather than inlining code).
|
||||||
|
fn opening_tag_has_src(open_tag: &str) -> bool {
|
||||||
|
open_tag
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.split(|c: char| c.is_whitespace() || c == '/')
|
||||||
|
.any(|token| token == "src" || token.starts_with("src="))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII-case-insensitive substring search. The returned byte offset is a valid
|
||||||
|
/// `str` boundary because `needle` (and therefore every matched byte) is ASCII.
|
||||||
|
fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
|
||||||
|
let (hay, ndl) = (haystack.as_bytes(), needle.as_bytes());
|
||||||
|
if ndl.is_empty() || hay.len() < ndl.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(0..=hay.len() - ndl.len())
|
||||||
|
.find(|&start| hay[start..start + ndl.len()].eq_ignore_ascii_case(ndl))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_inline_script_content_verbatim() {
|
||||||
|
// Leading/trailing whitespace inside the tag is part of what the browser
|
||||||
|
// hashes, so it must be preserved exactly.
|
||||||
|
let html = "<head><script>\n alert(1);\n</script></head>";
|
||||||
|
assert_eq!(inline_scripts(html), vec!["\n alert(1);\n"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_external_src_scripts() {
|
||||||
|
let html = r#"<script src="/app.js"></script><script>boot();</script>"#;
|
||||||
|
assert_eq!(inline_scripts(html), vec!["boot();"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_inline_module_skips_module_with_src() {
|
||||||
|
let html =
|
||||||
|
r#"<script type="module" src="/x.js"></script><script type="module">go();</script>"#;
|
||||||
|
assert_eq!(inline_scripts(html), vec!["go();"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn case_insensitive_tag_matching() {
|
||||||
|
let html = "<SCRIPT>run();</SCRIPT>";
|
||||||
|
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 = "<script>x()</script><script>x()</script>";
|
||||||
|
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()"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-23
@@ -49,7 +49,8 @@ use oxicloud::interfaces;
|
|||||||
use common::di::AppServiceFactory;
|
use common::di::AppServiceFactory;
|
||||||
use infrastructure::db::create_database_pools;
|
use infrastructure::db::create_database_pools;
|
||||||
use interfaces::{
|
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<SocketAddr, String> {
|
fn parse_addr(host: &str, port: u16) -> Result<SocketAddr, String> {
|
||||||
@@ -789,28 +790,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
app = app
|
app = app
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
HeaderName::from_static("content-security-policy"),
|
HeaderName::from_static("content-security-policy"),
|
||||||
// Note: 'unsafe-inline' is required for style-src because the
|
// Built at startup: script-src is 'self' plus a SHA-256 hash for
|
||||||
// frontend JavaScript dynamically sets inline styles (e.g.,
|
// every inline <script> in the served HTML, so the policy stays
|
||||||
// element.style.display = 'none'). This is a common pattern
|
// strict (no 'unsafe-inline' for scripts) while the SvelteKit SPA's
|
||||||
// for UI state management and cannot be easily migrated to
|
// inline bootstrap can still run. The full directive set, and why
|
||||||
// external CSS classes without significant refactoring.
|
// style-src/frame-src/media-src look the way they do, live in
|
||||||
// frame-src: '*' only matches network schemes, so 'blob:' must be
|
// interfaces::web::content_security_policy.
|
||||||
// listed explicitly for inline PDF/document viewers.
|
HeaderValue::from_str(&content_security_policy(&config))
|
||||||
// media-src: needed for blob: video/audio playback.
|
.expect("CSP header value contains only ASCII"),
|
||||||
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'",
|
|
||||||
),
|
|
||||||
))
|
))
|
||||||
.layer(SetResponseHeaderLayer::overriding(
|
.layer(SetResponseHeaderLayer::overriding(
|
||||||
HeaderName::from_static("x-content-type-options"),
|
HeaderName::from_static("x-content-type-options"),
|
||||||
|
|||||||
Reference in New Issue
Block a user