diff --git a/CHANGELOG.md b/CHANGELOG.md index af251db..8f5a6b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm lines before evaluating negotiation and validators. - Enforce the RFC 9110 qvalue grammar instead of clamping arbitrary floating-point values. +- Apply the configured Content Security Policy to status responses generated + by `EmbeddedSpa`, not only to the HTML entry point. ## [0.1.1] - 2026-07-27 diff --git a/README.md b/README.md index 3dbda51..cf6b2bc 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ routes correctly. `/api/*` must be handled before the SPA fallback. - MIME is inferred from the logical filename, not from `.gz` or `.br`. - `index.html`, immutable assets, revalidated assets, and errors have separate cache policies. +- A configured CSP covers the HTML entry point and status responses generated + by this crate. - Invalid configured header values fail at startup. ## What this crate does not do @@ -68,6 +70,8 @@ routes correctly. `/api/*` must be handled before the SPA fallback. - It does not run Vite, npm, pnpm, Bun, or another frontend build. - It does not compress responses at runtime. - It does not implement API routes, authentication, WebSockets, or sessions. +- It does not add security headers to application routes or redirects outside + this crate; use application middleware or the reverse proxy for those. - It does not configure TLS, Nginx, a CDN, or a service worker. - It does not preserve old hashed chunks across deployments. - It does not make embedded secrets safe. Embedded bytes are public assets. @@ -264,7 +268,9 @@ let config = EmbeddedSpaConfig::default() ``` Construction validates all configured response header values and verifies that -the index exists. +the index exists. CSP is attached to the HTML entry point and the empty +`404`/`405`/`406`/internal-error responses generated by `EmbeddedSpa`; it is not +attached to unrelated Axum routes or redirects. ## Nginx in front diff --git a/src/lib.rs b/src/lib.rs index c5b946e..b00381e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,7 +49,8 @@ pub struct EmbeddedSpaConfig { pub revalidate_cache_control: String, /// Cache policy for errors generated by this crate. pub error_cache_control: String, - /// Optional Content Security Policy applied to the HTML entry point. + /// Optional Content Security Policy applied to the HTML entry point and + /// status responses generated by this crate. pub content_security_policy: Option, } @@ -77,7 +78,7 @@ impl EmbeddedSpaConfig { } /// Override or remove the Content Security Policy applied to the entry - /// point. + /// point and crate-generated status responses. #[must_use] pub fn with_content_security_policy(mut self, value: Option>) -> Self { self.content_security_policy = value.map(Into::into); @@ -274,6 +275,10 @@ where builder = builder.header(name, value); } + if let Some(csp) = &self.config.content_security_policy { + builder = builder.header(header::CONTENT_SECURITY_POLICY, csp); + } + builder .body(Body::empty()) .expect("status response is valid") diff --git a/tests/http_contract.rs b/tests/http_contract.rs index 6a1f71d..12d0270 100644 --- a/tests/http_contract.rs +++ b/tests/http_contract.rs @@ -173,6 +173,56 @@ async fn invalid_html_quality_does_not_enable_fallback() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[test] +fn configured_csp_covers_entry_and_generated_status_responses() { + let spa = spa(); + let entry = spa.serve(request("/")); + let expected = entry.headers()[header::CONTENT_SECURITY_POLICY].clone(); + + let missing = spa.serve(request("/assets/missing.js")); + let mut method_not_allowed = request("/"); + *method_not_allowed.method_mut() = Method::POST; + let method_not_allowed = spa.serve(method_not_allowed); + let not_acceptable = spa.serve( + Request::builder() + .uri("/") + .header(header::ACCEPT_ENCODING, "identity;q=0, *;q=0") + .body(Body::empty()) + .unwrap(), + ); + + for (response, status) in [ + (missing, StatusCode::NOT_FOUND), + (method_not_allowed, StatusCode::METHOD_NOT_ALLOWED), + (not_acceptable, StatusCode::NOT_ACCEPTABLE), + ] { + assert_eq!(response.status(), status); + assert_eq!( + response.headers()[header::CONTENT_SECURITY_POLICY], + expected + ); + } +} + +#[test] +fn disabling_csp_removes_it_from_entry_and_status_responses() { + let spa = EmbeddedSpa::::new( + EmbeddedSpaConfig::default().with_content_security_policy(None::), + ) + .unwrap(); + + for response in [ + spa.serve(request("/")), + spa.serve(request("/assets/missing.js")), + ] { + assert!( + !response + .headers() + .contains_key(header::CONTENT_SECURITY_POLICY) + ); + } +} + #[tokio::test] async fn direct_compressed_paths_are_hidden() { let response = spa().serve(request("/index.html.br"));