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:
+49
-1
@@ -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),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user