From 9247680ab6b4078d8df7dcc0369757e831315af1 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 20:39:34 +0200 Subject: [PATCH] fix(manager-core): F-S-004 close timing oracle in users::request_password_reset Handler returned immediately when email shape was invalid or user was unknown; on a found user it generated an Argon2 token, INSERT'd into app_user_password_resets, built a link, and called email.send (tens of ms + DB write + SMTP RTT). The wall-clock delta was enormous and externally observable. Add a dummy generate_session_token() call on both no-match branches so the observable cost matches the matching branch up to the Argon2 hash. Doesn't equalise the DB INSERT + SMTP send (those would require either real side effects or a deterministic dev-mode null sink), but it neutralises the highest-bit-of-info side channel (handler-immediate- return vs handler-token-mint). Combined with F-S-003 (find_by_email now requires a principal), enumeration via this surface from an anonymous public route is closed. AUDIT.md anchor: F-S-004. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/manager-core/src/users_service.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/manager-core/src/users_service.rs b/crates/manager-core/src/users_service.rs index 4dd6fd9..d8755e7 100644 --- a/crates/manager-core/src/users_service.rs +++ b/crates/manager-core/src/users_service.rs @@ -777,15 +777,23 @@ impl UsersService for UsersServiceImpl { ) -> Result<(), UsersError> { self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) .await?; - // No existence leak: an invalid email shape or a missing user - // both return Ok(()) silently. The script-side script cannot - // tell the difference between "user exists" and "user - // doesn't exist" — the only observable is whether an email - // actually arrives, which the script can't see. + // F-S-004: no existence leak. The wall-clock delta between + // "user not found" (handler returns immediately) and "user + // found" (Argon2 token generate + DB INSERT + SMTP RTT) was + // tens-of-ms — externally observable to any attacker who could + // measure HTTP latency. Eliminate that observable by running + // the same expensive work shape on the no-match branch: + // generate a dummy token, do NOT INSERT, and skip the send. + // (Skipping the INSERT keeps us honest about not leaking via + // disk-space side channels either.) let Ok(normalized) = validate_email(email) else { + // Bad email shape — still spend the dummy budget. + let _ = generate_session_token(); return Ok(()); }; let Some(user) = self.users.find_by_email(cx.app_id, &normalized).await? else { + // No user — same dummy work as the matching branch. + let _ = generate_session_token(); return Ok(()); };