fix(manager-core): F-S-002 rate-limit users::request_password_reset + send_verification_email

Both methods short-circuit the authz gate when cx.principal == None.
An attacker hitting any public route could trigger unbounded outbound
email per call to arbitrary or attacker-supplied addresses — exhausting
SMTP-relay quota, getting the relay blacklisted, or burning provider
credits.

Add an in-memory EmailRateLimiter on UsersServiceImpl with two scopes:
- Per-(app, recipient): 5 calls per rolling 1h window
- Per-app daily cap: 200 calls per rolling 24h window

Both windows reset lazily. Token-bucket counts increment only after
both checks pass — a denied recipient doesn't consume the app counter.

Wired:
- send_verification_email: returns UsersError::EmailRateLimited (→ 429
  on the admin HTTP surface).
- request_password_reset: returns Ok(()) silently on rate-limit (would
  otherwise enable an existence-leak side channel).

UsersError::EmailRateLimited variant added; AppUsersApiError gains the
same variant + 429 mapping. NoopUsersError trait stub unaffected.

AUDIT.md anchor: F-S-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:07:53 +02:00
parent 47ea239eb6
commit e7f9200c8f
3 changed files with 144 additions and 0 deletions

View File

@@ -170,6 +170,14 @@ pub enum UsersError {
#[error("email transport: {0}")]
EmailTransport(String),
/// Email-tied surface (verification / password reset / invite) was
/// rate-limited. Either the per-(app, recipient) burst limit or the
/// per-app daily cap was exceeded. Surfaces silently to script-land
/// in the reset path (to avoid an existence-leak side channel) but
/// is raised explicitly to admin-mediated callers.
#[error("email send rate-limited")]
EmailRateLimited,
/// Underlying storage error. Surfaces to script-land as a generic
/// runtime error; the message is logged server-side for forensics.
#[error("backend error: {0}")]