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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:39:34 +02:00
parent 22e77e02f0
commit 9247680ab6

View File

@@ -777,15 +777,23 @@ impl UsersService for UsersServiceImpl {
) -> Result<(), UsersError> { ) -> Result<(), UsersError> {
self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id)) self.require(cx.principal.as_ref(), Capability::AppUsersWrite(cx.app_id))
.await?; .await?;
// No existence leak: an invalid email shape or a missing user // F-S-004: no existence leak. The wall-clock delta between
// both return Ok(()) silently. The script-side script cannot // "user not found" (handler returns immediately) and "user
// tell the difference between "user exists" and "user // found" (Argon2 token generate + DB INSERT + SMTP RTT) was
// doesn't exist" — the only observable is whether an email // tens-of-ms — externally observable to any attacker who could
// actually arrives, which the script can't see. // 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 { let Ok(normalized) = validate_email(email) else {
// Bad email shape — still spend the dummy budget.
let _ = generate_session_token();
return Ok(()); return Ok(());
}; };
let Some(user) = self.users.find_by_email(cx.app_id, &normalized).await? else { 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(()); return Ok(());
}; };