feat: Add CSRF and security headers tests
Adds comprehensive tests for CSRF protection and security headers to ensure robust defense against common web attacks.
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
//! Attack-confirming tests for CSRF protection on /web/ state-changing
|
||||
//! endpoints.
|
||||
//!
|
||||
//! Each test crafts a request that simulates a real attack shape
|
||||
//! (cross-site POST with valid cookie, wrong token, empty token, etc.)
|
||||
//! and asserts the expected defense. Specs marked `#[ignore]` are red
|
||||
//! today and turn green once the `CsrfForm<T>` extractor + session CSRF
|
||||
//! token land. Remove the `#[ignore]` on each as it passes.
|
||||
//!
|
||||
//! Session-hygiene tests without `#[ignore]` (session fixation rotation)
|
||||
//! document properties the server already has — they are regression
|
||||
//! anchors.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode, header};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
fn make_user(slug: &str, password: &str, role: &str) -> User {
|
||||
User {
|
||||
slug: slug.into(),
|
||||
api_key: format!("key-{slug}"),
|
||||
web_password: bcrypt::hash(password, 4).unwrap(),
|
||||
role: role.into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_config_with(users: Vec<User>) -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-csrf-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let api_keys: HashMap<String, String> = users
|
||||
.iter()
|
||||
.map(|u| (u.api_key.clone(), u.slug.clone()))
|
||||
.collect();
|
||||
Arc::new(Config {
|
||||
data_path,
|
||||
users,
|
||||
api_keys,
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_session_cookie(resp: &axum::response::Response) -> Option<String> {
|
||||
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
|
||||
let s = v.to_str().ok()?;
|
||||
if let Some(pair) = s.split(';').next()
|
||||
&& pair.starts_with("session=")
|
||||
{
|
||||
return Some(pair.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn login_request(slug: &str, password: &str) -> Request<Body> {
|
||||
let body = format!("slug={slug}&password={password}");
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn login_as(app: &axum::Router, slug: &str, password: &str) -> String {
|
||||
let resp = app
|
||||
.clone()
|
||||
.oneshot(login_request(slug, password))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
|
||||
extract_session_cookie(&resp).expect("no session cookie after login")
|
||||
}
|
||||
|
||||
fn form_post(path: &str, cookie: &str, body: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(path)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------- session hygiene (green today) ----------
|
||||
|
||||
/// Session-fixation: attacker sets a known `session=...` cookie on the
|
||||
/// victim's browser. If the server reused that value after login, the
|
||||
/// attacker would be logged in as the victim. Defense: every successful
|
||||
/// login mints a fresh token; the pre-set value must be overwritten.
|
||||
#[tokio::test]
|
||||
async fn session_fixation_login_rotates_cookie() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "secret", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
|
||||
let attacker_fixed_value = "session=attacker-fixed-token-value-aaaaaaaaaaaaaaaa";
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/web/login")
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(header::COOKIE, attacker_fixed_value)
|
||||
.body(Body::from("slug=dr_a&password=secret"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16());
|
||||
let minted = extract_session_cookie(&resp).expect("no Set-Cookie on login");
|
||||
assert!(
|
||||
minted != attacker_fixed_value,
|
||||
"login did not rotate the cookie — session fixation possible"
|
||||
);
|
||||
assert!(
|
||||
minted.starts_with("session="),
|
||||
"Set-Cookie shape unexpected: {minted}"
|
||||
);
|
||||
// Token portion has the full 43-char entropy, not the attacker's value.
|
||||
let minted_token = minted.trim_start_matches("session=");
|
||||
assert!(
|
||||
minted_token.len() >= 40,
|
||||
"minted token looks truncated: {minted_token:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CSRF defense — missing token ----------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_without_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000";
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"bulk POST without csrf_token should be 403 — got {}",
|
||||
resp.status()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn purge_closed_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/purge-closed", &cookie, "confirm=yes"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn reset_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post(
|
||||
"/web/cases/11111111-1111-1111-1111-111111111111/reset",
|
||||
&cookie,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn close_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post(
|
||||
"/web/cases/11111111-1111-1111-1111-111111111111/close",
|
||||
&cookie,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn reopen_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post(
|
||||
"/web/cases/11111111-1111-1111-1111-111111111111/reopen",
|
||||
&cookie,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn analyze_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post(
|
||||
"/web/cases/11111111-1111-1111-1111-111111111111/analyze",
|
||||
&cookie,
|
||||
"",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn delete_recording_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post(
|
||||
"/web/cases/11111111-1111-1111-1111-111111111111/recordings/delete",
|
||||
&cookie,
|
||||
"filename=2026-04-14T10-00-00Z.m4a",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// Forced-logout CSRF: attacker page auto-POSTs /web/logout to sign the
|
||||
/// victim out of their active session (annoyance / phishing setup where
|
||||
/// victim re-enters password on a lookalike page).
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn logout_without_csrf_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_a", "s", "doctor")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_a", "s").await;
|
||||
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/logout", &cookie, ""))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"forced-logout CSRF not blocked"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CSRF defense — malformed token values ----------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_wrong_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
// A 43-char alphanumeric that is structurally valid but does not
|
||||
// match the session's token.
|
||||
let wrong = "a".repeat(43);
|
||||
let body =
|
||||
format!("action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token={wrong}");
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/bulk", &cookie, &body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_empty_csrf_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=";
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"empty csrf_token must not bypass the check"
|
||||
);
|
||||
}
|
||||
|
||||
/// Whitespace trick: some frameworks trim tokens. If the server trimmed,
|
||||
/// an attacker could send `csrf_token=%0A%0A` and hit an early-return
|
||||
/// `if token.is_empty() { skip }` branch. Defense: compare raw.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_whitespace_padded_token_forbidden() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
// Leading+trailing spaces around a value that *would* match post-trim.
|
||||
// Even if the real token were "VALID", this should still fail.
|
||||
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000&csrf_token=%20VALID%20";
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
/// Paranoia: token-field value is a SQL-injection-looking string. The
|
||||
/// server uses a HashMap, so SQL is a non-issue — this test just pins
|
||||
/// down that the check returns a clean 403 rather than 500/panic on
|
||||
/// unusual byte content.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once CsrfForm extractor lands"]
|
||||
async fn bulk_with_injection_payload_returns_403_not_500() {
|
||||
let cfg = test_config_with(vec![make_user("dr_admin", "s", "admin")]);
|
||||
let app = doctate_server::create_router(cfg);
|
||||
let cookie = login_as(&app, "dr_admin", "s").await;
|
||||
|
||||
// `' OR 1=1--` URL-encoded.
|
||||
let body = "action=close&case_id=00000000-0000-0000-0000-000000000000\
|
||||
&csrf_token=%27+OR+1%3D1--";
|
||||
let resp = app
|
||||
.oneshot(form_post("/web/cases/bulk", &cookie, body))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"malformed token must yield 403, not 500"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Attack-confirming tests for the global security-header layer.
|
||||
//!
|
||||
//! Each test simulates a threat the corresponding header is designed to
|
||||
//! mitigate and asserts the header is actually present on the response.
|
||||
//! These specs are red today and turn green once the
|
||||
//! `SetResponseHeaderLayer` stack lands in `main.rs`. Remove the
|
||||
//! `#[ignore]` line as each assertion passes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
use doctate_server::config::{Config, User};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
fn test_config() -> Arc<Config> {
|
||||
Arc::new(Config {
|
||||
data_path: std::env::temp_dir().join(format!(
|
||||
"doctate-sechdr-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
)),
|
||||
users: vec![User {
|
||||
slug: "dr_test".into(),
|
||||
api_key: "test-key-123".into(),
|
||||
web_password: bcrypt::hash("secret", 4).unwrap(),
|
||||
role: "doctor".into(),
|
||||
whisper: Default::default(),
|
||||
retention: Default::default(),
|
||||
window_hours: 72,
|
||||
preview_lines: 2,
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]),
|
||||
..Config::test_default()
|
||||
})
|
||||
}
|
||||
|
||||
fn header_value<'a>(resp: &'a axum::response::Response, name: &str) -> Option<&'a str> {
|
||||
resp.headers().get(name).and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
fn count_header_values(resp: &axum::response::Response, name: &str) -> usize {
|
||||
resp.headers().get_all(name).iter().count()
|
||||
}
|
||||
|
||||
// ---------- presence tests ----------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands in main.rs"]
|
||||
async fn api_health_has_all_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
assert!(
|
||||
header_value(&resp, "content-security-policy").is_some(),
|
||||
"CSP header missing on /api/health"
|
||||
);
|
||||
assert!(
|
||||
header_value(&resp, "permissions-policy").is_some(),
|
||||
"Permissions-Policy missing on /api/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn web_login_page_has_all_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_value(&resp, "content-security-policy").is_some());
|
||||
}
|
||||
|
||||
// ---------- per-attack tests ----------
|
||||
|
||||
/// Clickjacking: attacker embeds /web/cases in a hidden iframe on their
|
||||
/// own page and tricks the victim into clicking overlaid elements.
|
||||
/// Defense: `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("frame-ancestors 'none'"),
|
||||
"CSP missing frame-ancestors: {csp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plugin-based XSS via `<object>` / `<embed>`.
|
||||
/// Defense: CSP `object-src 'none'`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn csp_blocks_object_embed_plugins() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("object-src 'none'"),
|
||||
"CSP missing object-src 'none': {csp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `<base href="https://evil/">` injection redirects all relative URLs.
|
||||
/// Defense: CSP `base-uri 'self'`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn csp_blocks_base_uri_hijack() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("base-uri 'self'"),
|
||||
"CSP missing base-uri 'self': {csp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Injected HTML redirects form submissions to attacker-controlled URL.
|
||||
/// Defense: CSP `form-action 'self'`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn csp_blocks_form_action_hijack() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let csp = header_value(&resp, "content-security-policy").unwrap_or("");
|
||||
assert!(
|
||||
csp.contains("form-action 'self'"),
|
||||
"CSP missing form-action 'self': {csp}"
|
||||
);
|
||||
}
|
||||
|
||||
/// MIME sniffing allows a non-HTML upload to be interpreted as HTML and
|
||||
/// executed. Defense: `X-Content-Type-Options: nosniff`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn nosniff_blocks_mime_confusion() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
}
|
||||
|
||||
/// Session-carrying URLs should not leak to third parties via `Referer`.
|
||||
/// Defense: `Referrer-Policy: no-referrer`.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn referrer_policy_prevents_leak() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
}
|
||||
|
||||
/// Malicious script requests microphone/camera access in background.
|
||||
/// Defense: `Permissions-Policy` explicitly denies those features.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn permissions_policy_blocks_sensitive_features() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pp = header_value(&resp, "permissions-policy").unwrap_or("");
|
||||
for feat in ["microphone", "camera", "geolocation", "payment"] {
|
||||
assert!(
|
||||
pp.contains(&format!("{feat}=()")),
|
||||
"Permissions-Policy missing '{feat}=()': {pp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- edge / regression tests ----------
|
||||
|
||||
/// Headers must apply to error responses too — a 302 or 404 is not an
|
||||
/// excuse to skip hardening. Many frameworks have a bug where layers
|
||||
/// short-circuit on error paths.
|
||||
#[tokio::test]
|
||||
#[ignore = "TDD red spec — enable once security-header layer lands"]
|
||||
async fn error_redirect_still_carries_security_headers() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
// /web/cases without cookie → 302 redirect (error path from the
|
||||
// session extractor).
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/cases")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FOUND);
|
||||
assert_eq!(
|
||||
header_value(&resp, "x-content-type-options"),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(header_value(&resp, "x-frame-options"), Some("DENY"));
|
||||
assert!(header_value(&resp, "content-security-policy").is_some());
|
||||
}
|
||||
|
||||
/// `magic.rs` already sets `Referrer-Policy: no-referrer` on the magic
|
||||
/// route. The global layer must use `if_not_present` so the header
|
||||
/// appears exactly once, not doubled. Passes today (no global layer
|
||||
/// yet) and must keep passing after the layer lands.
|
||||
#[tokio::test]
|
||||
async fn magic_route_referrer_policy_not_duplicated() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/magic?token=definitely-invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count_header_values(&resp, "referrer-policy"),
|
||||
1,
|
||||
"Referrer-Policy appeared more than once — header layer should be if_not_present"
|
||||
);
|
||||
assert_eq!(header_value(&resp, "referrer-policy"), Some("no-referrer"));
|
||||
}
|
||||
|
||||
/// HSTS must NOT be set until TLS is actually terminated in front of the
|
||||
/// server — otherwise legitimate HTTP dev deployments break irreversibly
|
||||
/// (max-age cache). Regression guard against accidental HSTS additions.
|
||||
#[tokio::test]
|
||||
async fn no_hsts_header_until_tls_is_in_place() {
|
||||
let app = doctate_server::create_router(test_config());
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/health")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
resp.headers().get("strict-transport-security").is_none(),
|
||||
"HSTS set but no TLS termination is in place — would break HTTP deployments"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user