feat: add defense-in-depth security-header layer

Attaches a SetResponseHeaderLayer stack to create_router_with_state
(not main.rs) so tests observe the same response shape as production.
`if_not_present` mode so per-route overrides (magic.rs already sets
its own Referrer-Policy) are preserved.

Sets: X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Content-Security-Policy, Permissions-Policy. HSTS is deliberately
omitted until TLS termination is in place — a cached max-age on a
plain-HTTP deployment is irreversible.

CSP uses 'unsafe-inline' for script/style since templates contain
inline scripts; revisit if any user input ever renders unescaped.

Removes #[ignore] from the 10 attack-confirming header tests; two
regression anchors (no-HSTS, no-duplicated magic header) were already
green.
This commit is contained in:
2026-04-22 09:33:46 +02:00
parent 34aa633d32
commit f3d0380dbd
3 changed files with 52 additions and 15 deletions
+49 -1
View File
@@ -18,6 +18,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
use axum::Router;
use axum::extract::FromRef;
use axum::http::{HeaderName, HeaderValue};
use tower_http::set_header::SetResponseHeaderLayer;
use analyze::AnalyzeSender;
use config::Config;
@@ -194,5 +196,51 @@ pub fn create_router(config: Arc<Config>) -> Router {
}
pub fn create_router_with_state(state: AppState) -> Router {
routes::api_router().with_state(state)
with_security_headers(routes::api_router().with_state(state))
}
/// Content-Security-Policy for all responses. `'unsafe-inline'` on
/// script-src / style-src is a pragmatic concession — the templates
/// include inline scripts (e.g. partials/time_format.js, localStorage
/// toggles) and user content is always escaped by askama before
/// rendering, so the XSS surface is small. Revisit if ever rendering
/// unescaped user input.
const CSP: &str = "default-src 'self'; \
script-src 'self' 'unsafe-inline'; \
style-src 'self' 'unsafe-inline'; \
img-src 'self' data:; \
media-src 'self'; \
object-src 'none'; \
base-uri 'self'; \
frame-ancestors 'none'; \
form-action 'self'";
const PERMISSIONS_POLICY: &str = "microphone=(), camera=(), geolocation=(), payment=()";
/// Defense-in-depth security headers applied to every response.
/// `if_not_present` respects per-route overrides (see routes/magic.rs,
/// which sets its own Referrer-Policy). HSTS is deliberately omitted
/// until TLS termination is in place; adding it prematurely on a
/// plain-HTTP deployment is irreversible (browser caches max-age).
fn with_security_headers(router: Router) -> Router {
router
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-content-type-options"),
HeaderValue::from_static("nosniff"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("x-frame-options"),
HeaderValue::from_static("DENY"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("referrer-policy"),
HeaderValue::from_static("no-referrer"),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(CSP),
))
.layer(SetResponseHeaderLayer::if_not_present(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static(PERMISSIONS_POLICY),
))
}