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(()); };