Compare commits

..

1 Commits

Author SHA1 Message Date
cupcakearmy 0c33f2f34c feat: enable strict CSP with nonce-based script-src
Replaces the disabled CSP middleware with a working implementation:
- Generates a per-request nonce for script-src
- Injects nonce into the inline SvelteKit bootstrap script
- Uses 'strict-dynamic' so dynamically imported modules are trusted
- SPA fallback serves index.html with CSP header
2026-06-25 21:34:25 +01:00
2 changed files with 33 additions and 16 deletions
+27 -8
View File
@@ -1,16 +1,35 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response}; use axum::{
http::HeaderValue,
response::{Html, IntoResponse, Response},
};
use ring::rand::SecureRandom;
use std::sync::OnceLock;
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy"; const CSP_POLICY: &str = "default-src 'self'; script-src 'nonce-{nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; connect-src 'self'";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! { fn index_html() -> &'static str {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE); static HTML: OnceLock<String> = OnceLock::new();
HTML.get_or_init(|| {
let path = format!("{}index.html", *crate::config::FRONTEND_PATH);
std::fs::read_to_string(&path).expect("Failed to read index.html for CSP injection")
})
} }
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response { fn generate_nonce() -> String {
let mut response = next.run(request).await; let rng = ring::rand::SystemRandom::new();
let mut bytes = [0u8; 32];
rng.fill(&mut bytes).expect("Failed to generate CSP nonce");
bs62::encode_data(&bytes)
}
pub async fn spa_fallback() -> Response {
let nonce = generate_nonce();
let csp = CSP_POLICY.replace("{nonce}", &nonce);
let html = index_html().replace("<script>", &format!("<script nonce=\"{}\">", nonce));
let mut response = Html(html).into_response();
response response
.headers_mut() .headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone()); .insert("Content-Security-Policy", HeaderValue::from_str(&csp).unwrap());
response response
} }
+5 -7
View File
@@ -12,7 +12,7 @@ use tower::Layer;
use tower_http::{ use tower_http::{
compression::CompressionLayer, compression::CompressionLayer,
normalize_path::NormalizePathLayer, normalize_path::NormalizePathLayer,
services::{ServeDir, ServeFile}, services::ServeDir,
}; };
#[macro_use] #[macro_use]
@@ -50,14 +50,12 @@ async fn main() {
.merge(health_routes) .merge(health_routes)
.merge(status_routes); .merge(status_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new() let app = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.fallback_service(serve_dir) .fallback_service(
// Disabled for now, as svelte inlines scripts ServeDir::new(config::FRONTEND_PATH.to_string())
// .layer(middleware::from_fn(csp::add_csp_header)) .not_found_service(axum::Router::new().fallback(csp::spa_fallback)),
)
.layer(DefaultBodyLimit::max(*config::LIMIT)) .layer(DefaultBodyLimit::max(*config::LIMIT))
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()