Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,17 @@ 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

- 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.
Expand Down Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

Expand Down Expand Up @@ -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<impl Into<String>>) -> Self {
self.content_security_policy = value.map(Into::into);
Expand Down Expand Up @@ -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")
Expand Down
50 changes: 50 additions & 0 deletions tests/http_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<FixtureAssets>::new(
EmbeddedSpaConfig::default().with_content_security_policy(None::<String>),
)
.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"));
Expand Down