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:
+1
-1
@@ -19,7 +19,7 @@ uuid.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tower-http = { version = "0.6", features = ["limit", "trace"] }
|
tower-http = { version = "0.6", features = ["limit", "trace", "set-header"] }
|
||||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|||||||
+49
-1
@@ -18,6 +18,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::extract::FromRef;
|
use axum::extract::FromRef;
|
||||||
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
|
|
||||||
use analyze::AnalyzeSender;
|
use analyze::AnalyzeSender;
|
||||||
use config::Config;
|
use config::Config;
|
||||||
@@ -194,5 +196,51 @@ pub fn create_router(config: Arc<Config>) -> Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_router_with_state(state: AppState) -> 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),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! Each test simulates a threat the corresponding header is designed to
|
//! Each test simulates a threat the corresponding header is designed to
|
||||||
//! mitigate and asserts the header is actually present on the response.
|
//! mitigate and asserts the header is actually present on the response.
|
||||||
//! These specs are red today and turn green once the
|
//! The layer is composed in `doctate_server::with_security_headers`,
|
||||||
//! `SetResponseHeaderLayer` stack lands in `main.rs`. Remove the
|
//! applied to every response by `create_router_with_state`.
|
||||||
//! `#[ignore]` line as each assertion passes.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -50,7 +49,6 @@ fn count_header_values(resp: &axum::response::Response, name: &str) -> usize {
|
|||||||
// ---------- presence tests ----------
|
// ---------- presence tests ----------
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands in main.rs"]
|
|
||||||
async fn api_health_has_all_security_headers() {
|
async fn api_health_has_all_security_headers() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -80,7 +78,6 @@ async fn api_health_has_all_security_headers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn web_login_page_has_all_security_headers() {
|
async fn web_login_page_has_all_security_headers() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -103,7 +100,6 @@ async fn web_login_page_has_all_security_headers() {
|
|||||||
/// own page and tricks the victim into clicking overlaid elements.
|
/// own page and tricks the victim into clicking overlaid elements.
|
||||||
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
|
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -126,7 +122,6 @@ async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
|||||||
/// Plugin-based XSS via `<object>` / `<embed>`.
|
/// Plugin-based XSS via `<object>` / `<embed>`.
|
||||||
/// Defense: CSP `object-src 'none'`.
|
/// Defense: CSP `object-src 'none'`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn csp_blocks_object_embed_plugins() {
|
async fn csp_blocks_object_embed_plugins() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -148,7 +143,6 @@ async fn csp_blocks_object_embed_plugins() {
|
|||||||
/// `<base href="https://evil/">` injection redirects all relative URLs.
|
/// `<base href="https://evil/">` injection redirects all relative URLs.
|
||||||
/// Defense: CSP `base-uri 'self'`.
|
/// Defense: CSP `base-uri 'self'`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn csp_blocks_base_uri_hijack() {
|
async fn csp_blocks_base_uri_hijack() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -170,7 +164,6 @@ async fn csp_blocks_base_uri_hijack() {
|
|||||||
/// Injected HTML redirects form submissions to attacker-controlled URL.
|
/// Injected HTML redirects form submissions to attacker-controlled URL.
|
||||||
/// Defense: CSP `form-action 'self'`.
|
/// Defense: CSP `form-action 'self'`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn csp_blocks_form_action_hijack() {
|
async fn csp_blocks_form_action_hijack() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -192,7 +185,6 @@ async fn csp_blocks_form_action_hijack() {
|
|||||||
/// MIME sniffing allows a non-HTML upload to be interpreted as HTML and
|
/// MIME sniffing allows a non-HTML upload to be interpreted as HTML and
|
||||||
/// executed. Defense: `X-Content-Type-Options: nosniff`.
|
/// executed. Defense: `X-Content-Type-Options: nosniff`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn nosniff_blocks_mime_confusion() {
|
async fn nosniff_blocks_mime_confusion() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -213,7 +205,6 @@ async fn nosniff_blocks_mime_confusion() {
|
|||||||
/// Session-carrying URLs should not leak to third parties via `Referer`.
|
/// Session-carrying URLs should not leak to third parties via `Referer`.
|
||||||
/// Defense: `Referrer-Policy: no-referrer`.
|
/// Defense: `Referrer-Policy: no-referrer`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn referrer_policy_prevents_leak() {
|
async fn referrer_policy_prevents_leak() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -231,7 +222,6 @@ async fn referrer_policy_prevents_leak() {
|
|||||||
/// Malicious script requests microphone/camera access in background.
|
/// Malicious script requests microphone/camera access in background.
|
||||||
/// Defense: `Permissions-Policy` explicitly denies those features.
|
/// Defense: `Permissions-Policy` explicitly denies those features.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn permissions_policy_blocks_sensitive_features() {
|
async fn permissions_policy_blocks_sensitive_features() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
let resp = app
|
let resp = app
|
||||||
@@ -258,7 +248,6 @@ async fn permissions_policy_blocks_sensitive_features() {
|
|||||||
/// excuse to skip hardening. Many frameworks have a bug where layers
|
/// excuse to skip hardening. Many frameworks have a bug where layers
|
||||||
/// short-circuit on error paths.
|
/// short-circuit on error paths.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
|
||||||
async fn error_redirect_still_carries_security_headers() {
|
async fn error_redirect_still_carries_security_headers() {
|
||||||
let app = doctate_server::create_router(test_config());
|
let app = doctate_server::create_router(test_config());
|
||||||
// /web/cases without cookie → 302 redirect (error path from the
|
// /web/cases without cookie → 302 redirect (error path from the
|
||||||
|
|||||||
Reference in New Issue
Block a user