1591 lines
No EOL
58 KiB
Rust
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()
|
|
} |