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

@@ -408,6 +408,9 @@ pub enum AppUsersApiError {
#[error("email is not configured")]
EmailNotConfigured,
#[error("email send rate-limited")]
EmailRateLimited,
#[error("backend error: {0}")]
Backend(String),
@@ -423,6 +426,7 @@ impl From<UsersError> for AppUsersApiError {
UsersError::DuplicateEmail => Self::DuplicateEmail,
UsersError::Invalid(msg) => Self::Invalid(msg),
UsersError::EmailNotConfigured => Self::EmailNotConfigured,
UsersError::EmailRateLimited => Self::EmailRateLimited,
UsersError::EmailTransport(msg) | UsersError::Backend(msg) => Self::Backend(msg),
}
}
@@ -458,6 +462,10 @@ impl IntoResponse for AppUsersApiError {
StatusCode::SERVICE_UNAVAILABLE,
json!({ "error": self.to_string() }),
),
Self::EmailRateLimited => (
StatusCode::TOO_MANY_REQUESTS,
json!({ "error": self.to_string() }),
),
Self::Backend(e) => {
tracing::error!(error = %e, "app users admin backend error");
(