//! Comprehensive security test suite. //! //! Covers authentication, authorisation, input validation, rate-limiting, //! session management, information-disclosure, IDOR, and business-logic //! vulnerabilities. //! //! Key improvements over the original: //! * Uses the shared `TestClient` — no duplicated HTTP logic. //! * Route paths come from `test_routes` — consistent with the server. //! * Timing attack test uses statistical sampling (30 samples, 5 warm-up). //! * bcrypt truncation is correctly detected. //! * Race-condition test runs operations concurrently. //! * `Outcome::Skip` is used for tests that depend on failed setup steps. //! * Warnings vs failures are correctly classified. use std::sync::Arc; use std::time::Instant; use tokio::time::{sleep, Duration}; use tracing::{info, warn}; use ulid::Ulid; use nigig_common::{ AppError, CreateRoomRequest, CreateRoomResponse, JoinRoomRequest, JoinRoomResponse, LeaveRoomRequest, LeaveRoomResponse, LoginRequest, LoginResponse, LogoutRequest, LogoutResponse, MessageContent, RefreshTokenRequest, RefreshTokenResponse, RegisterRequest, RegisterResponse, SendMessageRequest, SendMessageResponse, SyncRequest, SyncResponse, }; use crate::security_payloads::{ admin_only_paths, header_injection_payloads, json_injection_payloads, malformed_json_payloads, malicious_role_payload, nosql_payloads, null_payloads, predictable_user_ids, sensitive_error_patterns, sqli_payloads, traversal_payloads, unicode_long_emoji, unicode_payloads_static, xss_payloads, FAKE_JWT, }; use crate::shared_test_client::{build_client, TestClient, TestResults}; use crate::test_routes as R; /// Wait for the rate-limit window to expire. /// /// Called after tests that intentionally trigger rate limits to ensure /// subsequent tests aren't affected. async fn wait_for_rate_limit_reset() { let wait_secs = crate::shared::LOCKOUT_DURATION_SECS as u64 + 1; tracing::info!("⏳ Waiting {wait_secs}s for rate-limit window to reset..."); tokio::time::sleep(tokio::time::Duration::from_secs(wait_secs)).await; } // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── const BASE_URL: &str = "http://127.0.0.1:8080"; /// Samples per timing-attack measurement bucket. const TIMING_SAMPLES: usize = 30; /// Warm-up requests before collecting timing samples. const TIMING_WARMUP: usize = 5; /// Maximum acceptable mean timing difference (µs) before a warning fires. const TIMING_WARN_THRESHOLD_US: f64 = 50_000.0; // 50 ms // ───────────────────────────────────────────────────────────────────────────── // Setup helpers // ───────────────────────────────────────────────────────────────────────────── /// Register a fresh user and set the access token on `client`. async fn register( client: &mut TestClient, username: &str, password: &str, display: &str, ) -> Result { let resp: RegisterResponse = client .post( R::AUTH_REGISTER, &RegisterRequest { username: username.to_string(), password: password.to_string(), display_name: Some(display.to_string()), device_display_name: None, }, ) .await?; client.set_token(resp.tokens.access_token.token.clone()); Ok(resp) } fn uid8() -> String { Ulid::new().to_string()[..8].to_string() } // ───────────────────────────────────────────────────────────────────────────── // Entry point // ───────────────────────────────────────────────────────────────────────────── pub async fn run_security_tests() -> Result<(), AppError> { info!("╔══════════════════════════════════════════════════════════════╗"); info!("║ 🔐 Security Test Suite 🔐 ║"); info!("╚══════════════════════════════════════════════════════════════╝"); sleep(Duration::from_millis(200)).await; let pool = build_client(); let mut r = TestResults::new("Security"); // ── Principal accounts ──────────────────────────────────────────────────── let mut attacker = TestClient::with_pool(Arc::clone(&pool), BASE_URL); let mut victim = TestClient::with_pool(Arc::clone(&pool), BASE_URL); let mut admin_user = TestClient::with_pool(Arc::clone(&pool), BASE_URL); let attacker_username = format!("attacker_{}", uid8()); let victim_username = format!("victim_{}", uid8()); let admin_username = format!("admin_{}", uid8()); let attacker_password = "AttackerPass1!"; let victim_password = "VictimPass1!"; let attacker_resp = match register(&mut attacker, &attacker_username, attacker_password, "Attacker").await { Ok(resp) => resp, Err(e) => { return Err(AppError::Internal { message: format!("Security suite setup failed (attacker register): {e}"), }) } }; let victim_resp = match register(&mut victim, &victim_username, victim_password, "Victim").await { Ok(resp) => resp, Err(e) => { return Err(AppError::Internal { message: format!("Security suite setup failed (victim register): {e}"), }) } }; let victim_refresh = victim_resp.tokens.refresh_token.token.clone(); let _admin_resp = match register(&mut admin_user, &admin_username, "AdminPass1!", "Admin").await { Ok(resp) => resp, Err(e) => { return Err(AppError::Internal { message: format!("Security suite setup failed (admin register): {e}"), }) } }; // Victim creates a private room let victim_room_id = match victim .post::<_, CreateRoomResponse>( R::rooms_create(), &CreateRoomRequest { name: Some("Victim's Private Room".to_string()), topic: Some("Private".to_string()), is_direct: false, invite: vec![], }, ) .await { Ok(resp) => resp.room_id.to_string(), Err(e) => { return Err(AppError::Internal { message: format!("Security suite setup failed (victim room): {e}"), }) } }; // ========================================================================= // SECTION 1 — Authentication Security // ========================================================================= info!("\n🔐 SECTION 1: Authentication Security"); info!("───────────────────────────────────────────────────────────────"); // 1.1 Empty Authorization header value { let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", "")], ).await?; if status == 401 { r.pass("Empty Authorization header rejected"); } else { r.fail("Empty Authorization header rejected", &format!("Got {status}")); } } // 1.2 Missing 'Bearer' prefix { let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", "some_random_token")], ).await?; if status == 401 { r.pass("Missing 'Bearer' prefix rejected"); } else { r.fail("Missing 'Bearer' prefix rejected", &format!("Got {status}")); } } // 1.3 'Bearer ' with empty token { let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", "Bearer ")], ).await?; if status == 401 { r.pass("'Bearer ' rejected"); } else { r.fail("'Bearer ' rejected", &format!("Got {status}")); } } // 1.4 Garbage token { let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", "Bearer garbage_token_12345")], ).await?; if status == 401 { r.pass("Garbage token rejected"); } else { r.fail("Garbage token rejected", &format!("Got {status}")); } } // 1.5 JWT (wrong format for PASETO server) { let auth = format!("Bearer {FAKE_JWT}"); let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", &auth)], ).await?; if status == 401 { r.pass("JWT token rejected (server uses PASETO)"); } else { r.fail("JWT token rejected", &format!("Got {status}")); } } // 1.6 Tampered PASETO token (single byte flip) { let mut tampered = attacker_resp.tokens.access_token.token.clone(); if tampered.len() > 50 { // Safety: PASETO tokens are ASCII; flipping one char is safe. let idx = 45; let byte = tampered.as_bytes()[idx]; let replacement = if byte == b'A' { b'B' } else { b'A' }; tampered.replace_range(idx..=idx, &(replacement as char).to_string()); } let auth = format!("Bearer {tampered}"); let (status, _) = attacker.raw_request( "POST", R::SYNC, Some("{}".into()), &[("content-type", "application/json"), ("authorization", &auth)], ).await?; if status == 401 { r.pass("Tampered PASETO token rejected"); } else { r.fail("Tampered PASETO token rejected", &format!("Got {status}")); } } // 1.7 Access token cannot be used as a refresh token { match attacker .post::<_, RefreshTokenResponse>( R::AUTH_REFRESH, &RefreshTokenRequest { token: attacker_resp.tokens.access_token.token.clone(), }, ) .await { Err(_) => r.pass("Access token rejected at /auth/refresh"), Ok(_) => r.fail("Access token rejected at /auth/refresh", "Was accepted as refresh token"), } } // 1.8 Refresh token cannot be used as an access token // ── Use a fresh connection to avoid reusing a server-closed socket ──── // { // let fresh = build_client(); // let mut alice2 = TestClient::with_pool(Arc::clone(&fresh), BASE_URL); // alice2.set_token(/* copy alice's current token here if needed */ // alice_resp.tokens.access_token.token.clone() // ); // match alice2 // .post::<_, RefreshTokenResponse>( // R::AUTH_REFRESH, // &RefreshTokenRequest { token: alice_refresh }, // ) // .await // { // Ok(resp) => { // alice.set_token(resp.tokens.access_token.token); // r.pass("Refresh access token"); // } // Err(e) => r.fail("Refresh access token", &e.to_string()), // } // } { let mut probe = attacker.fork(); probe.set_token(attacker_resp.tokens.refresh_token.token.clone()); match probe .post::<_, SyncResponse>( R::SYNC, &SyncRequest { since: None, timeout_ms: Some(100), filter: None }, ) .await { Err(_) => r.pass("Refresh token rejected at protected endpoint"), Ok(_) => r.fail("Refresh token rejected at protected endpoint", "Was accepted as access token"), } } // 1.9 Stolen refresh token — warn if bearer semantics allow it; // this is expected behaviour for pure bearer tokens but worth noting. { match attacker .post::<_, RefreshTokenResponse>( R::AUTH_REFRESH, &RefreshTokenRequest { token: victim_refresh.clone() }, ) .await { Ok(_) => { // Bearer tokens are not device-bound by default; this is a // design trade-off, not necessarily a vulnerability. r.warn( "Refresh token is a pure bearer token", "Consider binding tokens to device fingerprint or IP", ); } Err(_) => r.pass("Victim's refresh token rejected when used by attacker"), } } // 1.10 SQL injection payloads in username field { let payloads = sqli_payloads(); let mut any_accepted = false; for payload in payloads.iter().take(5) { if attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: payload.to_string(), password: "password".to_string(), device_display_name: None, }, ) .await .is_ok() { any_accepted = true; r.fail("SQL injection payloads rejected", &format!("Payload accepted: {payload}")); break; } } if !any_accepted { r.pass("SQL injection payloads rejected"); } } // 1.11 NoSQL injection payloads in username field { let payloads = nosql_payloads(); let mut any_accepted = false; for payload in payloads.iter().take(5) { if attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: payload.to_string(), password: "password".to_string(), device_display_name: None, }, ) .await .is_ok() { any_accepted = true; r.fail("NoSQL injection payloads rejected", &format!("Payload accepted: {payload}")); break; } } if !any_accepted { r.pass("NoSQL injection payloads rejected"); } } // 1.12 Empty password { match attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: attacker_username.clone(), password: String::new(), device_display_name: None, }, ) .await { Err(_) => r.pass("Empty password rejected"), Ok(_) => r.fail("Empty password rejected", "Login succeeded with empty password"), } } // 1.13 bcrypt truncation vulnerability test // // bcrypt silently truncates passwords to 72 bytes. If the server does // not pre-hash or validate password length, a 73-char password and the // same 73-char password with extra suffix are treated identically, // meaning an attacker who knows the first 72 bytes can authenticate. { let bcrypt_test_user = format!("bcrypt_{}", uid8()); let long_pass = format!("{}{}", "x".repeat(72), "EXTRA_SUFFIX_THAT_SHOULD_MATTER"); let mut probe = attacker.fork(); let reg_result = probe .post::<_, RegisterResponse>( R::AUTH_REGISTER, &RegisterRequest { username: bcrypt_test_user.clone(), password: long_pass.clone(), display_name: None, device_display_name: None, }, ) .await; match reg_result { Err(_) => { // Server rejects long passwords at registration — safe. r.pass("Long password rejected at registration (bcrypt safe)"); } Ok(_) => { // Server accepted a >72-byte password. Now try to log in // with only the first 72 bytes — if it succeeds the server // is vulnerable to bcrypt truncation. let truncated = "x".repeat(72); // Same as the first 72 bytes of long_pass match probe .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: bcrypt_test_user.clone(), password: truncated, device_display_name: None, }, ) .await { Ok(_) => r.fail( "bcrypt truncation vulnerability", "Login succeeded with only the first 72 bytes of a longer password", ), Err(_) => r.pass("No bcrypt truncation vulnerability (72-byte boundary safe)"), } // Also verify an extremely long password doesn't cause DoS. let very_long = "y".repeat(100_000); let start = Instant::now(); let _ = probe .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: bcrypt_test_user, password: very_long, device_display_name: None, }, ) .await; let elapsed = start.elapsed(); if elapsed.as_secs() > 5 { r.warn( "Long password DoS potential", &format!("Request took {}s — ensure password length is capped server-side", elapsed.as_secs()), ); } else { r.pass("Very long password handled without DoS"); } } } } // ========================================================================= // SECTION 2 — Authorisation // ========================================================================= info!("\n🔐 SECTION 2: Authorisation Security"); info!("───────────────────────────────────────────────────────────────"); // 2.1 Send to room without being a member { match attacker .post::<_, SendMessageResponse>( &R::room_send(&victim_room_id), &SendMessageRequest { content: MessageContent::text("Unauthorised message"), reply_to: None, }, ) .await { Err(_) => r.pass("Non-member cannot send message"), Ok(_) => r.fail("Non-member cannot send message", "Message accepted"), } } // 2.2 Read messages without membership { match attacker .post::<_, serde_json::Value>( &R::room_messages(&victim_room_id), &serde_json::json!({"limit": 50, "direction": "backward"}), ) .await { Err(_) => r.pass("Non-member cannot read room messages"), Ok(_) => r.fail("Non-member cannot read room messages", "Messages returned"), } } // 2.3 Leave a room never joined { match attacker .post::<_, LeaveRoomResponse>( &R::room_leave(&victim_room_id), &LeaveRoomRequest { reason: None }, ) .await { Err(_) => r.pass("Cannot leave a room never joined"), Ok(_) => r.fail("Cannot leave a room never joined", "Leave accepted"), } } // 2.4 Modify room settings without permission { match attacker .post::<_, serde_json::Value>( &R::room_settings(&victim_room_id), &serde_json::json!({"name": "Hacked", "topic": "Hacked"}), ) .await { Err(_) => r.pass("Cannot modify room settings without permission"), Ok(_) => r.fail("Cannot modify room settings without permission", "Settings changed"), } } // 2.5 Kick without permission { match attacker .post::<_, serde_json::Value>( &R::room_kick(&victim_room_id), &serde_json::json!({ "user_id": victim_resp.user_id.to_string(), "reason": "test" }), ) .await { Err(_) => r.pass("Cannot kick without permission"), Ok(_) => r.fail("Cannot kick without permission", "Kick accepted"), } } // 2.6 Ban without permission { match attacker .post::<_, serde_json::Value>( &R::room_ban(&victim_room_id), &serde_json::json!({ "user_id": victim_resp.user_id.to_string(), "reason": "test" }), ) .await { Err(_) => r.pass("Cannot ban without permission"), Ok(_) => r.fail("Cannot ban without permission", "Ban accepted"), } } // Attacker joins victim's room (for self-promotion tests) let _ = attacker .post::<_, JoinRoomResponse>( &R::room_join(&victim_room_id), &JoinRoomRequest { reason: None }, ) .await; // 2.7 Self-promotion to admin { match attacker .post::<_, serde_json::Value>( &R::room_role_set(&victim_room_id), &serde_json::json!({ "target_user_id": attacker_resp.user_id.to_string(), "role": "admin" }), ) .await { Err(_) => r.pass("Cannot self-promote to admin"), Ok(_) => r.fail("Cannot self-promote to admin", "Self-promotion accepted"), } } // 2.8 Self-promotion to owner { match attacker .post::<_, serde_json::Value>( &R::room_role_set(&victim_room_id), &serde_json::json!({ "target_user_id": attacker_resp.user_id.to_string(), "role": "owner" }), ) .await { Err(_) => r.pass("Cannot self-promote to owner"), Ok(_) => r.fail("Cannot self-promote to owner", "Self-promotion to owner accepted"), } } // 2.9 Demote the room owner { match attacker .post::<_, serde_json::Value>( &R::room_role_set(&victim_room_id), &serde_json::json!({ "target_user_id": victim_resp.user_id.to_string(), "role": "member" }), ) .await { Err(_) => r.pass("Cannot demote room owner"), Ok(_) => r.fail("Cannot demote room owner", "Owner demoted"), } } // 2.10 Create custom role without permission { let payload = malicious_role_payload(&attacker_resp.user_id.to_string()); match attacker .post::<_, serde_json::Value>(&R::room_roles_create(&victim_room_id), &payload) .await { Err(_) => r.pass("Cannot create custom role without permission"), Ok(_) => r.fail("Cannot create custom role without permission", "Role created"), } } // 2.11 Assign role without permission { match attacker .post::<_, serde_json::Value>( &R::room_roles_assign(&victim_room_id), &serde_json::json!({ "target_user_id": attacker_resp.user_id.to_string(), "role_id": "some_fake_role_id" }), ) .await { Err(_) => r.pass("Cannot assign role without permission"), Ok(_) => r.fail("Cannot assign role without permission", "Role assigned"), } } // 2.12 Admin-only endpoints inaccessible to regular users { let token = attacker_resp.tokens.access_token.token.clone(); let auth = format!("Bearer {token}"); let paths = admin_only_paths(); let mut all_blocked = true; for path in paths { let (status, _) = attacker .raw_request("GET", path, None, &[("authorization", &auth)]) .await?; if status == 200 { all_blocked = false; r.fail("Admin endpoints protected", &format!("{path} returned 200")); break; } } if all_blocked { r.pass("Admin-only endpoints protected from regular users"); } } // ========================================================================= // SECTION 3 — Input Validation // ========================================================================= info!("\n🔐 SECTION 3: Input Validation"); info!("───────────────────────────────────────────────────────────────"); // Attacker creates their own room for send-path tests let attacker_room_id = match attacker .post::<_, CreateRoomResponse>( R::rooms_create(), &CreateRoomRequest { name: Some("Attacker Room".to_string()), topic: None, is_direct: false, invite: vec![], }, ) .await { Ok(resp) => resp.room_id.to_string(), Err(e) => { r.fail("Attacker room setup", &e.to_string()); String::new() } }; // 3.1 XSS payloads in message content (stored XSS — server must not crash) if !attacker_room_id.is_empty() { let send_path = R::room_send(&attacker_room_id); let mut server_crashed = false; for payload in xss_payloads() { let (status, _) = attacker .raw_request( "POST", &send_path, Some(serde_json::json!({ "content": {"type": "text", "body": payload} }) .to_string()), &[ ("content-type", "application/json"), ("authorization", &format!("Bearer {}", attacker_resp.tokens.access_token.token)), ], ) .await?; if status == 500 { server_crashed = true; r.fail("XSS payload handling", &format!("Server 500 on payload: {payload}")); break; } } if !server_crashed { r.pass("XSS payloads handled (no server crash — client must sanitize output)"); } } else { r.skip("XSS payload handling", "Attacker room setup failed"); } // 3.2 Path traversal via room ID { let token = attacker_resp.tokens.access_token.token.clone(); let auth = format!("Bearer {token}"); let mut all_blocked = true; for payload in traversal_payloads() { let path = format!("/api/v1/rooms/{payload}/messages"); let (status, _) = attacker .raw_request( "POST", &path, Some(r#"{"limit":10}"#.to_string()), &[("content-type", "application/json"), ("authorization", &auth)], ) .await?; if status == 200 { all_blocked = false; r.fail("Path traversal protection", &format!("Payload worked: {payload}")); break; } } if all_blocked { r.pass("Path traversal payloads rejected"); } } // 3.3 Null byte injection in messages (server must not crash) if !attacker_room_id.is_empty() { let send_path = R::room_send(&attacker_room_id); let mut crashed = false; for payload in null_payloads() { let req = SendMessageRequest { content: MessageContent::text(payload), reply_to: None, }; let (status, _) = attacker .raw_request( "POST", &send_path, Some(serde_json::to_string(&req).unwrap_or_default()), &[ ("content-type", "application/json"), ("authorization", &format!("Bearer {}", attacker_resp.tokens.access_token.token)), ], ) .await?; if status == 500 { crashed = true; r.fail("Null byte injection handling", "Server returned 500"); break; } } if !crashed { r.pass("Null byte payloads handled without server crash"); } } else { r.skip("Null byte injection handling", "Attacker room setup failed"); } // 3.4 Unicode abuse (server must not crash) if !attacker_room_id.is_empty() { let send_path = R::room_send(&attacker_room_id); let long_emoji = unicode_long_emoji(); let static_payloads = unicode_payloads_static(); let mut all_payloads: Vec<&str> = static_payloads.to_vec(); all_payloads.push(&long_emoji); let mut crashed = false; for payload in all_payloads { let (status, _) = attacker .raw_request( "POST", &send_path, Some( serde_json::json!({ "content": {"type": "text", "body": payload} }) .to_string(), ), &[ ("content-type", "application/json"), ("authorization", &format!("Bearer {}", attacker_resp.tokens.access_token.token)), ], ) .await?; if status == 500 { crashed = true; r.fail("Unicode abuse handling", "Server returned 500"); break; } } if !crashed { r.pass("Unicode abuse payloads handled without server crash"); } } else { r.skip("Unicode abuse handling", "Attacker room setup failed"); } // 3.5 JSON injection (raw body strings — server must not crash or expose 500) { let token = attacker_resp.tokens.access_token.token.clone(); let auth = format!("Bearer {token}"); let mut any_500 = false; for payload in json_injection_payloads() { let (status, _) = attacker .raw_request( "POST", &R::room_send(if attacker_room_id.is_empty() { "dummy" } else { &attacker_room_id }), Some(payload.to_string()), &[("content-type", "application/json"), ("authorization", &auth)], ) .await?; if status == 500 { any_500 = true; r.fail("JSON injection handling", "Server returned 500"); break; } } if !any_500 { r.pass("JSON injection payloads handled without 500"); } } // 3.6 Malformed JSON bodies (server must return 4xx, not 500) { let mut any_500 = false; for payload in malformed_json_payloads() { let body = String::from_utf8_lossy(payload).into_owned(); let (status, _) = attacker .raw_request( "POST", R::AUTH_LOGIN, Some(body), &[("content-type", "application/json")], ) .await?; if status == 500 { any_500 = true; r.fail("Malformed JSON handling", "Server returned 500 instead of 4xx"); break; } } if !any_500 { r.pass("Malformed JSON returns 4xx (no 500)"); } } // 3.7 Oversized payload (10 MB — should be 413 or 400, never 500) { let large_body = serde_json::json!({ "content": {"type": "text", "body": "x".repeat(10_000_000)} }) .to_string(); let (status, _) = attacker .raw_request( "POST", &R::room_send(if attacker_room_id.is_empty() { "dummy" } else { &attacker_room_id }), Some(large_body), &[ ("content-type", "application/json"), ("authorization", &format!("Bearer {}", attacker_resp.tokens.access_token.token)), ], ) .await?; match status { 200 | 400 | 413 => r.pass("Oversized payload accepted or properly rejected (no crash)"), 500 => r.fail("Oversized payload handling", "Server crashed with 500"), _ => r.warn("Oversized payload handling", &format!("Unexpected status {status}")), } } // 3.8 Content-Type mismatch (must not cause 500) { let (status, _) = attacker .raw_request( "POST", R::AUTH_LOGIN, Some(r#"{"username":"test","password":"test"}"#.to_string()), &[("content-type", "text/plain")], ) .await?; if status == 500 { r.fail("Content-Type mismatch handling", "Server returned 500"); } else { r.pass("Content-Type mismatch handled (no 500)"); } } // 3.9 Unsupported HTTP methods (must not cause 500) { let mut any_500 = false; for method in &["PUT", "DELETE", "PATCH", "TRACE"] { let (status, _) = attacker .raw_request( method, R::AUTH_LOGIN, Some("{}".to_string()), &[("content-type", "application/json")], ) .await?; if status == 500 { any_500 = true; r.fail("HTTP method tampering", &format!("{method} caused 500")); break; } } if !any_500 { r.pass("Unsupported HTTP methods handled (no 500)"); } } // 3.10 Header injection { let auth = format!("Bearer {}", attacker_resp.tokens.access_token.token); let mut any_500 = false; for (key, value) in header_injection_payloads() { let (status, _) = attacker .raw_request( "POST", R::SYNC, Some("{}".to_string()), &[ ("content-type", "application/json"), ("authorization", &auth), (key, value), ], ) .await?; if status == 500 { any_500 = true; r.fail("Header injection handling", "Server returned 500"); break; } } if !any_500 { r.pass("Header injection payloads handled (no 500)"); } } // ========================================================================= // SECTION 4 — Rate Limiting // ========================================================================= info!("\n🔐 SECTION 4: Rate Limiting"); info!("───────────────────────────────────────────────────────────────"); // 4.1 Login brute force { let mut rate_limited_at: Option = None; for i in 0..150usize { match attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: format!("ghost_{i}"), password: "wrongpassword".to_string(), device_display_name: None, }, ) .await { Err(e) => { let s = e.to_string(); if s.contains("429") || s.contains("rate") || s.contains("Too Many") { rate_limited_at = Some(i); break; } } Ok(_) => {} } } match rate_limited_at { Some(i) => { info!(" Rate limited after {i} login attempts"); r.pass("Login brute force rate-limited"); } None => r.warn( "Login brute force rate limiting", "No rate limit detected after 150 attempts", ), } } let wait_secs = crate::shared::LOCKOUT_DURATION_SECS as u64 + 1; info!("⏳ Waiting {wait_secs}s for rate-limit window to reset..."); tokio::time::sleep(tokio::time::Duration::from_secs(wait_secs)).await; // 4.2 API endpoint burst { let mut rate_limited_at: Option = None; for i in 0..200usize { match attacker .post::<_, SyncResponse>( R::SYNC, &SyncRequest { since: None, timeout_ms: Some(10), filter: None }, ) .await { Err(e) => { let s = e.to_string(); if s.contains("429") || s.contains("rate") { rate_limited_at = Some(i); break; } } Ok(_) => {} } } match rate_limited_at { Some(i) => { info!(" API rate limited after {i} requests"); r.pass("API endpoint rate limiting active"); } None => r.warn( "API endpoint rate limiting", "No rate limit after 200 rapid requests", ), } } wait_for_rate_limit_reset().await; // ========================================================================= // SECTION 5 — Session Security // ========================================================================= info!("\n🔐 SECTION 5: Session Security"); info!("───────────────────────────────────────────────────────────────"); // 5.1 Token is invalidated after logout { let username = format!("logout_test_{}", uid8()); let mut temp = attacker.fork(); let resp: RegisterResponse = temp .post( R::AUTH_REGISTER, &RegisterRequest { username: username.clone(), password: "TempPass1!".to_string(), display_name: None, device_display_name: None, }, ) .await?; let token_before = resp.tokens.access_token.token.clone(); temp.set_token(token_before.clone()); temp.post::<_, LogoutResponse>( R::AUTH_LOGOUT, &LogoutRequest { all_devices: false }, ) .await?; let mut check = attacker.fork(); check.set_token(token_before); match check .post::<_, SyncResponse>( R::SYNC, &SyncRequest { since: None, timeout_ms: Some(100), filter: None }, ) .await { Err(_) => r.pass("Token invalidated after logout"), Ok(_) => r.fail("Token invalidated after logout", "Old token still valid"), } } // 5.2 Logout-all invalidates all sessions { let username = format!("multi_{}", uid8()); let mut d1 = attacker.fork(); let resp: RegisterResponse = d1 .post( R::AUTH_REGISTER, &RegisterRequest { username: username.clone(), password: "MultiPass1!".to_string(), display_name: None, device_display_name: None, }, ) .await?; d1.set_token(resp.tokens.access_token.token); let mut d2 = attacker.fork(); let login2: LoginResponse = d2 .post( R::AUTH_LOGIN, &LoginRequest { username: username.clone(), password: "MultiPass1!".to_string(), device_display_name: Some("Device 2".to_string()), }, ) .await?; let d2_token = login2.tokens.access_token.token.clone(); d2.set_token(d2_token.clone()); d1.post::<_, LogoutResponse>( R::AUTH_LOGOUT, &LogoutRequest { all_devices: true }, ) .await?; let mut probe = attacker.fork(); probe.set_token(d2_token); match probe .post::<_, SyncResponse>( R::SYNC, &SyncRequest { since: None, timeout_ms: Some(100), filter: None }, ) .await { Err(_) => r.pass("Logout-all invalidates all sessions"), Ok(_) => r.fail("Logout-all invalidates all sessions", "Device 2 token still valid"), } } // 5.3 Concurrent session limit { let username = format!("concurrent_{}", uid8()); let mut first = attacker.fork(); let resp: RegisterResponse = first .post( R::AUTH_REGISTER, &RegisterRequest { username: username.clone(), password: "ConcurrentPass1!".to_string(), display_name: None, device_display_name: None, }, ) .await?; first.set_token(resp.tokens.access_token.token); let mut session_count = 1usize; let mut limit_hit = false; for i in 0..20usize { let mut c = attacker.fork(); match c .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: username.clone(), password: "ConcurrentPass1!".to_string(), device_display_name: Some(format!("Device {i}")), }, ) .await { Ok(r2) => { c.set_token(r2.tokens.access_token.token); session_count += 1; } Err(_) => { r.pass(&format!("Concurrent session limit enforced at {session_count} sessions")); limit_hit = true; break; } } } if !limit_hit { r.warn( "Concurrent session limit", &format!("No limit after {session_count} sessions — consider enforcing a cap"), ); } } // ========================================================================= // SECTION 6 — Information Disclosure // ========================================================================= info!("\n🔐 SECTION 6: Information Disclosure"); info!("───────────────────────────────────────────────────────────────"); // 6.1 Error messages must not leak internal details { let body = serde_json::to_string(&LoginRequest { username: "nonexistent_user_xyz".to_string(), password: "wrongpassword".to_string(), device_display_name: None, }) .unwrap(); let (_, resp_body) = attacker .raw_request( "POST", R::AUTH_LOGIN, Some(body), &[("content-type", "application/json")], ) .await?; let body_lower = String::from_utf8_lossy(&resp_body).to_lowercase(); let mut found_leak = false; for pattern in sensitive_error_patterns() { if body_lower.contains(pattern) { found_leak = true; r.fail( "Error messages must not leak sensitive info", &format!("Found '{pattern}' in response"), ); break; } } if !found_leak { r.pass("Error messages do not leak sensitive info"); } } // 6.2 User enumeration via registration error text { let body = serde_json::to_string(&RegisterRequest { username: attacker_username.clone(), password: "newpassword".to_string(), display_name: None, device_display_name: None, }) .unwrap(); let (_, resp_body) = attacker .raw_request( "POST", R::AUTH_REGISTER, Some(body), &[("content-type", "application/json")], ) .await?; let body_lower = String::from_utf8_lossy(&resp_body).to_lowercase(); if body_lower.contains("already exists") || body_lower.contains("taken") { r.warn( "User enumeration via registration", "Response reveals whether username is taken", ); } else { r.pass("Registration error does not reveal user existence"); } } // 6.3 Login timing consistency (statistical — 30 samples, 5 warm-up) { // Warm-up to stabilize TCP/TLS connection pools. for _ in 0..TIMING_WARMUP { let _ = attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: attacker_username.clone(), password: "wrong".to_string(), device_display_name: None, }, ) .await; } let mut existing_us = Vec::with_capacity(TIMING_SAMPLES); let mut ghost_us = Vec::with_capacity(TIMING_SAMPLES); for _ in 0..TIMING_SAMPLES { let t = Instant::now(); let _ = attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: attacker_username.clone(), password: "wrong_password_for_timing".to_string(), device_display_name: None, }, ) .await; existing_us.push(t.elapsed().as_micros()); let t = Instant::now(); let _ = attacker .post::<_, LoginResponse>( R::AUTH_LOGIN, &LoginRequest { username: format!("ghost_{}", Ulid::new()), password: "wrong_password_for_timing".to_string(), device_display_name: None, }, ) .await; ghost_us.push(t.elapsed().as_micros()); } let mean_existing: f64 = existing_us.iter().sum::() as f64 / TIMING_SAMPLES as f64; let mean_ghost: f64 = ghost_us.iter().sum::() as f64 / TIMING_SAMPLES as f64; let diff_ms = (mean_existing - mean_ghost).abs() / 1_000.0; info!( " Timing: existing={:.1}ms ghost={:.1}ms diff={:.1}ms (n={})", mean_existing / 1_000.0, mean_ghost / 1_000.0, diff_ms, TIMING_SAMPLES, ); if (mean_existing - mean_ghost).abs() > TIMING_WARN_THRESHOLD_US { r.warn( "Login timing consistency", &format!( "Mean diff = {diff_ms:.1}ms over {TIMING_SAMPLES} samples — \ possible user-enumeration via timing" ), ); } else { r.pass(&format!( "Login timing consistent (diff={diff_ms:.1}ms over {TIMING_SAMPLES} samples)" )); } } // ========================================================================= // SECTION 7 — IDOR // ========================================================================= info!("\n🔐 SECTION 7: IDOR"); info!("───────────────────────────────────────────────────────────────"); // 7.1 Access another user's profile — must not expose sensitive fields { match attacker .get::(&R::user_profile(&victim_resp.user_id.to_string())) .await { Ok(profile) => { let raw = serde_json::to_string(&profile).unwrap_or_default(); if raw.contains("password") || raw.contains("email") || raw.contains("phone") { r.fail("User profile IDOR", "Sensitive fields exposed in profile response"); } else { r.pass("User profile does not expose sensitive fields"); } } Err(_) => r.pass("User profile access restricted (non-member)"), } } // 7.2 Enumerate predictable user IDs { let ids = predictable_user_ids(); let mut found_any = false; for id in ids { if attacker .get::(&R::user_profile(id)) .await .is_ok() { found_any = true; r.warn( "Predictable user ID enumeration", &format!("Profile accessible via ID: {id}"), ); break; } } if !found_any { r.pass("Predictable user IDs not accessible"); } } // 7.3 Access event from a room the attacker hasn't joined { let secret_msg: SendMessageResponse = victim .post( &R::room_send(&victim_room_id), &SendMessageRequest { content: MessageContent::text("Secret message"), reply_to: None, }, ) .await?; match attacker .get::(&R::event(&secret_msg.event_id.to_string())) .await { Ok(_) => r.fail("Event IDOR", "Event from non-member room was accessible"), Err(_) => r.pass("Events from non-member rooms are not accessible"), } } // ========================================================================= // SECTION 8 — Business Logic // ========================================================================= info!("\n🔐 SECTION 8: Business Logic"); info!("───────────────────────────────────────────────────────────────"); // 8.1 Double-join is idempotent or correctly rejected { // Attacker is already in victim_room_id from earlier. match attacker .post::<_, JoinRoomResponse>( &R::room_join(&victim_room_id), &JoinRoomRequest { reason: None }, ) .await { Err(_) => r.pass("Double-join rejected cleanly"), Ok(_) => r.warn( "Double-join accepted", "Idempotent join is acceptable only if membership state remains consistent", ), } } // 8.2 Concurrent join/leave race condition // // Unlike the original, this test actually fires requests concurrently // via `tokio::spawn` so the server's synchronisation logic is exercised. { let race_room: CreateRoomResponse = attacker .post( R::rooms_create(), &CreateRoomRequest { name: Some("Race Room".to_string()), topic: None, is_direct: false, invite: vec![], }, ) .await?; let rr_id = race_room.room_id.to_string(); let join_path = R::room_join(&rr_id); let leave_path = R::room_leave(&rr_id); let mut handles = Vec::with_capacity(20); for _ in 0..10 { let c_join = attacker.fork(); let c_leave = attacker.fork(); let jp = join_path.clone(); let lp = leave_path.clone(); handles.push(tokio::spawn(async move { c_join .post::<_, JoinRoomResponse>(&jp, &JoinRoomRequest { reason: None }) .await .map(|_| ()) .unwrap_or(()) })); handles.push(tokio::spawn(async move { c_leave .post::<_, LeaveRoomResponse>(&lp, &LeaveRoomRequest { reason: None }) .await .map(|_| ()) .unwrap_or(()) })); } // Wait for all concurrent operations. for h in handles { let _ = h.await; } // The server should still respond correctly — not 500. let (status, _) = attacker .raw_request("GET", R::HEALTH, None, &[]) .await?; if status == 200 { r.pass("Server stable after concurrent join/leave race"); } else { r.fail( "Server stable after concurrent join/leave race", &format!("Health check returned {status} post-race"), ); } } // 8.3 Negative and overflow numeric values { let auth = format!("Bearer {}", attacker_resp.tokens.access_token.token); let (status, _) = attacker .raw_request( "POST", R::SYNC, Some(r#"{"since":null,"timeout_ms":-1,"filter":null}"#.to_string()), &[("content-type", "application/json"), ("authorization", &auth)], ) .await?; if status == 500 { r.fail("Negative timeout_ms handling", "Server crashed with 500"); } else { r.pass("Negative timeout_ms handled (no 500)"); } let (status, _) = attacker .raw_request( "POST", R::SYNC, Some(r#"{"since":null,"timeout_ms":9999999999999999,"filter":null}"#.to_string()), &[("content-type", "application/json"), ("authorization", &auth)], ) .await?; if status == 500 { r.fail("Overflow timeout_ms handling", "Server crashed with 500"); } else { r.pass("Overflow timeout_ms handled (no 500)"); } } // 8.4 Message ordering under rapid fire if !attacker_room_id.is_empty() { let send_path = R::room_send(&attacker_room_id); for i in 0..10usize { let _ = attacker .post::<_, SendMessageResponse>( &send_path, &SendMessageRequest { content: MessageContent::text(&format!("Rapid message {i}")), reply_to: None, }, ) .await; } match attacker .post::<_, serde_json::Value>( &R::room_messages(&attacker_room_id), &serde_json::json!({"limit": 20, "direction": "backward"}), ) .await { Ok(_) => r.pass("Message history retrievable after rapid-fire sends"), Err(e) => r.warn("Message ordering under rapid fire", &e.to_string()), } } else { r.skip("Message ordering under rapid fire", "Attacker room setup failed"); } r.into_result() }