harden(rate-limiter): fail secure login scopes with cache fallback
This commit is contained in:
@@ -9,9 +9,18 @@ use MintyPHP\Repository\Security\RateLimitRepositoryInterface;
|
||||
class RateLimiterService
|
||||
{
|
||||
private const HASH_ALGO = 'sha256';
|
||||
private const STORAGE_DEGRADED_RETRY_AFTER_SECONDS = 30;
|
||||
private const FALLBACK_TTL_BUFFER_SECONDS = 60;
|
||||
private const FAIL_SECURE_SCOPE_PREFIXES = [
|
||||
'api.auth.login.',
|
||||
'login.resolve.',
|
||||
'login.password.',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RateLimitRepositoryInterface $rateLimitRepository)
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RateLimitRepositoryInterface $rateLimitRepository,
|
||||
private readonly ?RateLimiterFallbackGateway $fallbackGateway = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function hit(string $scope, string $subject, int $maxHits, int $windowSeconds, int $blockSeconds): array
|
||||
@@ -57,9 +66,8 @@ class RateLimiterService
|
||||
'allowed' => false,
|
||||
'retry_after' => max(1, $blockedUntilTs - $nowTs),
|
||||
];
|
||||
} catch (\Throwable $exception) {
|
||||
// Fail-open: never lock users out because of rate limit storage issues.
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
} catch (\Throwable) {
|
||||
return $this->handleReadStorageFailure($normalized['scope'], $normalized['subject_hash']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,11 +181,165 @@ class RateLimiterService
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
} catch (\Throwable $exception) {
|
||||
// Fail-open on storage failures.
|
||||
return $this->handleApplyStorageFailure(
|
||||
$normalized['scope'],
|
||||
$normalized['subject_hash'],
|
||||
$maxHits,
|
||||
$windowSeconds,
|
||||
$blockSeconds,
|
||||
$increment
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return $this->handleApplyStorageFailure(
|
||||
$normalized['scope'],
|
||||
$normalized['subject_hash'],
|
||||
$maxHits,
|
||||
$windowSeconds,
|
||||
$blockSeconds,
|
||||
$increment
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleReadStorageFailure(string $scope, string $subjectHash): array
|
||||
{
|
||||
if ($this->fallbackGateway !== null) {
|
||||
try {
|
||||
return $this->fallbackIsBlocked($scope, $subjectHash);
|
||||
} catch (\Throwable) {
|
||||
// Fall through to policy fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->storageFailurePolicy($scope);
|
||||
}
|
||||
|
||||
private function handleApplyStorageFailure(
|
||||
string $scope,
|
||||
string $subjectHash,
|
||||
int $maxHits,
|
||||
int $windowSeconds,
|
||||
int $blockSeconds,
|
||||
bool $increment
|
||||
): array {
|
||||
if ($this->fallbackGateway !== null) {
|
||||
try {
|
||||
return $this->fallbackApply($scope, $subjectHash, $maxHits, $windowSeconds, $blockSeconds, $increment);
|
||||
} catch (\Throwable) {
|
||||
// Fall through to policy fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return $this->storageFailurePolicy($scope);
|
||||
}
|
||||
|
||||
private function fallbackIsBlocked(string $scope, string $subjectHash): array
|
||||
{
|
||||
$state = $this->fallbackGateway->load($scope, $subjectHash);
|
||||
if (!is_array($state)) {
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
$blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0));
|
||||
if ($blockedUntilTs <= 0) {
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
$nowTs = time();
|
||||
if ($blockedUntilTs <= $nowTs) {
|
||||
$this->fallbackGateway->remove($scope, $subjectHash);
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
return [
|
||||
'allowed' => false,
|
||||
'retry_after' => max(1, $blockedUntilTs - $nowTs),
|
||||
];
|
||||
}
|
||||
|
||||
private function fallbackApply(
|
||||
string $scope,
|
||||
string $subjectHash,
|
||||
int $maxHits,
|
||||
int $windowSeconds,
|
||||
int $blockSeconds,
|
||||
bool $increment
|
||||
): array {
|
||||
$nowTs = time();
|
||||
$state = $this->fallbackGateway->load($scope, $subjectHash);
|
||||
|
||||
$hits = 0;
|
||||
$windowStartedTs = $nowTs;
|
||||
$blockedUntilTs = 0;
|
||||
|
||||
if (is_array($state)) {
|
||||
$blockedUntilTs = max(0, (int) ($state['blocked_until'] ?? 0));
|
||||
if ($blockedUntilTs > $nowTs) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'retry_after' => max(1, $blockedUntilTs - $nowTs),
|
||||
];
|
||||
}
|
||||
|
||||
$windowStartedTs = max(0, (int) ($state['window_started_at'] ?? 0));
|
||||
$hits = max(0, (int) ($state['hits'] ?? 0));
|
||||
if ($windowStartedTs <= 0 || ($windowStartedTs + $windowSeconds) <= $nowTs) {
|
||||
$windowStartedTs = $nowTs;
|
||||
$hits = 0;
|
||||
}
|
||||
} elseif (!$increment) {
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
if ($increment) {
|
||||
$hits++;
|
||||
}
|
||||
|
||||
$blockedUntilTs = 0;
|
||||
if ($hits > $maxHits) {
|
||||
$blockedUntilTs = $nowTs + $blockSeconds;
|
||||
}
|
||||
|
||||
$expiresAtTs = $blockedUntilTs > 0 ? $blockedUntilTs : ($windowStartedTs + $windowSeconds);
|
||||
$ttlSeconds = max(1, ($expiresAtTs - $nowTs) + self::FALLBACK_TTL_BUFFER_SECONDS);
|
||||
|
||||
$this->fallbackGateway->save($scope, $subjectHash, [
|
||||
'hits' => $hits,
|
||||
'window_started_at' => $windowStartedTs,
|
||||
'blocked_until' => $blockedUntilTs > 0 ? $blockedUntilTs : null,
|
||||
], $ttlSeconds);
|
||||
|
||||
if ($blockedUntilTs > 0) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'retry_after' => max(1, $blockedUntilTs - $nowTs),
|
||||
];
|
||||
}
|
||||
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
private function storageFailurePolicy(string $scope): array
|
||||
{
|
||||
if ($this->isFailSecureScope($scope)) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'retry_after' => self::STORAGE_DEGRADED_RETRY_AFTER_SECONDS,
|
||||
];
|
||||
}
|
||||
|
||||
return ['allowed' => true, 'retry_after' => 0];
|
||||
}
|
||||
|
||||
private function isFailSecureScope(string $scope): bool
|
||||
{
|
||||
foreach (self::FAIL_SECURE_SCOPE_PREFIXES as $prefix) {
|
||||
if (str_starts_with($scope, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function normalize(string $scope, string $subject): ?array
|
||||
|
||||
Reference in New Issue
Block a user