af3377a6bc
Finishes the lift of shared helpers into `tests/common/`. Covered: - health, web, oneliners_api, upload (31 tests; upload exercises the new `multipart_upload_body` helper driven by doctate_common field constants) - sse_integration, sse_cleanup, transcribe, oneliner_heal_decoupled, silent_case_empty, failed_only_case_empty_oneliner, transient_failure_retries (24 tests) Also applied cargo fmt across the test tree and fixed one clippy needless_borrows_for_generic_args warning in analyze_test. All 387 tests pass; 3 ignored (as before). Side effect: health_test previously used a hardcoded `/tmp/doctate-test` data path, which parallel `cargo test` runs could collide on. The migration replaces it with the common unique-tmpdir pattern, removing a latent flake.
185 lines
6.8 KiB
Rust
185 lines
6.8 KiB
Rust
//! 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.
|
|
//! The layer is composed in `doctate_server::with_security_headers`,
|
|
//! applied to every response by `create_router_with_state`.
|
|
|
|
mod common;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::response::Response;
|
|
use tower::util::ServiceExt;
|
|
|
|
use common::{TestConfig, header_opt, test_user_with_password};
|
|
|
|
fn test_app() -> axum::Router {
|
|
let cfg = TestConfig::new()
|
|
.with_user(test_user_with_password("dr_test", "secret"))
|
|
.build();
|
|
doctate_server::create_router(cfg)
|
|
}
|
|
|
|
fn count_header_values(resp: &Response, name: &str) -> usize {
|
|
resp.headers().get_all(name).iter().count()
|
|
}
|
|
|
|
async fn get(app: axum::Router, uri: &str) -> Response {
|
|
app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
// ---------- presence tests ----------
|
|
|
|
#[tokio::test]
|
|
async fn api_health_has_all_security_headers() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
|
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
|
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
|
assert!(
|
|
header_opt(&resp, "content-security-policy").is_some(),
|
|
"CSP header missing on /api/health"
|
|
);
|
|
assert!(
|
|
header_opt(&resp, "permissions-policy").is_some(),
|
|
"Permissions-Policy missing on /api/health"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn web_login_page_has_all_security_headers() {
|
|
let resp = get(test_app(), "/web/login").await;
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
|
assert!(header_opt(&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]
|
|
async fn csp_blocks_clickjacking_via_frame_ancestors() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
|
let csp = header_opt(&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]
|
|
async fn csp_blocks_object_embed_plugins() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
let csp = header_opt(&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]
|
|
async fn csp_blocks_base_uri_hijack() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
let csp = header_opt(&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]
|
|
async fn csp_blocks_form_action_hijack() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
let csp = header_opt(&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]
|
|
async fn nosniff_blocks_mime_confusion() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
assert_eq!(header_opt(&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]
|
|
async fn referrer_policy_prevents_leak() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
assert_eq!(header_opt(&resp, "referrer-policy"), Some("no-referrer"));
|
|
}
|
|
|
|
/// Malicious script requests microphone/camera access in background.
|
|
/// Defense: `Permissions-Policy` explicitly denies those features.
|
|
#[tokio::test]
|
|
async fn permissions_policy_blocks_sensitive_features() {
|
|
let resp = get(test_app(), "/api/health").await;
|
|
let pp = header_opt(&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]
|
|
async fn error_redirect_still_carries_security_headers() {
|
|
// /web/cases without cookie → 302 redirect (error path from the
|
|
// session extractor).
|
|
let resp = get(test_app(), "/web/cases").await;
|
|
assert_eq!(resp.status(), StatusCode::FOUND);
|
|
assert_eq!(header_opt(&resp, "x-content-type-options"), Some("nosniff"));
|
|
assert_eq!(header_opt(&resp, "x-frame-options"), Some("DENY"));
|
|
assert!(header_opt(&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 resp = get(test_app(), "/web/magic?token=definitely-invalid").await;
|
|
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_opt(&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 resp = get(test_app(), "/api/health").await;
|
|
assert!(
|
|
resp.headers().get("strict-transport-security").is_none(),
|
|
"HSTS set but no TLS termination is in place — would break HTTP deployments"
|
|
);
|
|
}
|