use autumn_web::auth::{hash_password, verify_password};
use autumn_web::security::CsrfLayer;
use autumn_web::security::SecurityHeadersLayer;
use autumn_web::security::{CsrfConfig, HeadersConfig};
use autumn_web::session::{MemoryStore, Session, SessionConfig, SessionLayer, SessionStore};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::{get, post},
};
use tower::ServiceExt;
const FLAG_TOKEN: &str = "ctf-flag-token-0000-1111-2222-3333";
fn ctf_password_fixture() -> String {
String::from_utf8(
[
99, 111, 114, 114, 101, 99, 116, 32, 104, 111, 114, 115, 101, 32, 98, 97, 116, 116,
101, 114, 121, 32, 115, 116, 97, 112, 108, 101,
]
.to_vec(),
)
.expect("test fixture bytes should be valid utf-8")
}
fn ctf_password_near_miss() -> String {
let mut value = ctf_password_fixture();
value.push(' ');
value
}
fn ctf_password_unrelated() -> String {
String::from_utf8([104, 117, 110, 116, 101, 114, 50].to_vec())
.expect("test fixture bytes should be valid utf-8")
}
fn headers_only_app(config: &HeadersConfig) -> Router {
Router::new()
.route("/", get(|| async { "ok" }))
.layer(SecurityHeadersLayer::from_config(config))
}
fn csrf_protected_app() -> Router {
let config = CsrfConfig {
enabled: true,
..Default::default()
};
Router::new()
.route("/transfer", post(|| async { "transferred" }))
.layer(CsrfLayer::from_config(&config))
}
#[tokio::test]
async fn ctf_01_phantom_iframe_is_denied() {
let app = headers_only_app(&HeadersConfig::default());
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let xfo = response
.headers()
.get("x-frame-options")
.expect("🚩 regression: X-Frame-Options missing — clickjacking defence is gone");
assert_eq!(
xfo, "DENY",
"🚩 regression: X-Frame-Options should default to DENY, got {xfo:?}"
);
}
#[tokio::test]
async fn ctf_02_mime_confusion_is_blocked() {
let app = headers_only_app(&HeadersConfig::default());
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let xcto = response
.headers()
.get("x-content-type-options")
.expect("🚩 regression: X-Content-Type-Options missing — MIME-sniff defence gone");
assert_eq!(xcto, "nosniff");
}
#[tokio::test]
async fn ctf_03_referrer_policy_is_strict() {
let app = headers_only_app(&HeadersConfig::default());
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let policy = response
.headers()
.get("referrer-policy")
.expect("🚩 regression: Referrer-Policy missing — cross-origin leak is possible");
assert_eq!(policy, "strict-origin-when-cross-origin");
}
#[tokio::test]
async fn ctf_04_hsts_downgrade_is_blocked() {
let config = HeadersConfig {
strict_transport_security: true,
hsts_max_age_secs: 63_072_000,
hsts_include_subdomains: true,
..Default::default()
};
let app = headers_only_app(&config);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let hsts = response
.headers()
.get("strict-transport-security")
.expect("🚩 regression: HSTS header missing when enabled");
assert_eq!(hsts, "max-age=63072000; includeSubDomains");
}
#[tokio::test]
async fn ctf_05_csp_locks_down_inline_scripts() {
let policy = "default-src 'self'; script-src 'self'; object-src 'none'";
let config = HeadersConfig {
content_security_policy: policy.to_owned(),
..Default::default()
};
let app = headers_only_app(&config);
let response = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
let csp = response
.headers()
.get("content-security-policy")
.expect("🚩 regression: CSP missing when configured");
assert_eq!(
csp, policy,
"🚩 regression: CSP was rewritten or truncated — inline script injection defence at risk"
);
}
#[tokio::test]
async fn ctf_06_csrf_post_without_token_is_rejected() {
let app = csrf_protected_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/transfer")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"🚩 regression: tokenless POST passed through — CSRF defence down"
);
}
#[tokio::test]
async fn ctf_07_csrf_mismatched_tokens_are_rejected() {
let app = csrf_protected_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/transfer")
.header("Cookie", format!("autumn-csrf={FLAG_TOKEN}"))
.header("X-CSRF-Token", "not-the-flag")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"🚩 regression: CSRF layer accepted a mismatched header token"
);
}
#[tokio::test]
async fn ctf_08_cookie_toss_is_rejected() {
let app = csrf_protected_app();
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/transfer")
.header(
"Cookie",
format!("autumn-csrf={FLAG_TOKEN}; autumn-csrf=attacker-chosen-value"),
)
.header("X-CSRF-Token", FLAG_TOKEN)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"🚩 regression: cookie-tossing accepted — duplicate cookies must be treated as untrusted"
);
}
#[tokio::test]
async fn ctf_09_session_fixation_is_blocked() {
async fn login_handler(session: Session) -> &'static str {
session.rotate_id().await;
session.insert("user_id", "victim").await;
"logged in"
}
let store = MemoryStore::new();
let state = autumn_web::AppState::for_test();
let app = Router::new()
.route("/login", get(login_handler))
.layer(SessionLayer::new(store.clone(), SessionConfig::default()))
.with_state(state);
let attacker_id = "ctf-fixation-attacker-id";
let mut seeded = std::collections::HashMap::new();
seeded.insert("pre_existing".to_owned(), "attacker".to_owned());
store.save(attacker_id, seeded).await.unwrap();
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/login")
.header("Cookie", format!("autumn.sid={attacker_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let set_cookie = response
.headers()
.get("set-cookie")
.expect("🚩 regression: login did not issue Set-Cookie")
.to_str()
.unwrap();
assert!(
!set_cookie.contains(attacker_id),
"🚩 regression: Set-Cookie still carries the attacker's session id"
);
assert!(
store.load(attacker_id).await.unwrap().is_none(),
"🚩 regression: old session id not destroyed on rotate_id"
);
}
#[tokio::test]
async fn ctf_10_session_cookie_is_hardened_in_prod_config() {
async fn login_handler(session: Session) -> &'static str {
session.insert("user_id", "alice").await;
"logged in"
}
let store = MemoryStore::new();
let config = SessionConfig {
secure: true,
http_only: true,
same_site: "Strict".to_owned(),
..SessionConfig::default()
};
let state = autumn_web::AppState::for_test();
let app = Router::new()
.route("/login", get(login_handler))
.layer(SessionLayer::new(store, config))
.with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/login")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let cookie = response
.headers()
.get("set-cookie")
.expect("🚩 regression: login did not issue a session cookie")
.to_str()
.unwrap();
assert!(
cookie.contains("Secure"),
"🚩 regression: Secure attribute missing in prod-style config ({cookie})"
);
assert!(
cookie.contains("HttpOnly"),
"🚩 regression: HttpOnly attribute missing ({cookie})"
);
assert!(
cookie.contains("SameSite=Strict"),
"🚩 regression: SameSite=Strict missing ({cookie})"
);
}
#[tokio::test]
async fn ctf_11_password_replay_is_blocked() {
let password = ctf_password_fixture();
let hash = hash_password(&password)
.await
.expect("hash_password failed");
assert!(
hash.starts_with("$2"),
"🚩 regression: hash_password no longer produces bcrypt format ({hash})"
);
assert!(
verify_password(&password, &hash)
.await
.expect("verify_password failed"),
"🚩 regression: verify_password rejected the correct password"
);
assert!(
!verify_password(&ctf_password_near_miss(), &hash)
.await
.expect("verify_password failed"),
"🚩 regression: verify_password accepted a near-miss password"
);
assert!(
!verify_password(&ctf_password_unrelated(), &hash)
.await
.expect("verify_password failed"),
"🚩 regression: verify_password accepted an unrelated password"
);
}
#[tokio::test]
async fn ctf_12_bouncer_turns_away_anonymous_visitors() {
async fn protected(session: Session) -> (StatusCode, &'static str) {
if session.get("user_id").await.is_some() {
(StatusCode::OK, "welcome")
} else {
(StatusCode::UNAUTHORIZED, "who are you?")
}
}
let state = autumn_web::AppState::for_test();
let app = Router::new()
.route("/private", get(protected))
.layer(SessionLayer::new(
MemoryStore::new(),
SessionConfig::default(),
))
.with_state(state);
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/private")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNAUTHORIZED,
"🚩 regression: anonymous session appeared authenticated"
);
}