nigig-org/nimanyatta/src/security_tests.rs
andodeki fd8b0632ca
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Include nimanyatta as normal tree (not embedded git)
2026-09-26 09:29:36 +03:00

1591 lines
No EOL
58 KiB
Rust

//! 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<RegisterResponse, AppError> {
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 <empty>' rejected");
} else {
r.fail("'Bearer <empty>' 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<usize> = 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<usize> = 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::<u128>() as f64 / TIMING_SAMPLES as f64;
let mean_ghost: f64 =
ghost_us.iter().sum::<u128>() 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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()
}