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:
@@ -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");
|
||||
(
|
||||
|
||||
Reference in New Issue
Block a user