Fix WASA observations 5 and 6: user enumeration and login rate limiting
This commit is contained in:
parent
def0d4c80f
commit
367f63f4f3
|
|
@ -11,6 +11,12 @@ use CodeIgniter\Shield\Result;
|
|||
|
||||
class LegacySafeSession extends Session
|
||||
{
|
||||
/**
|
||||
* Dummy bcrypt hash with cost 10 used to equalize execution timing
|
||||
* when a username is not found in the database.
|
||||
*/
|
||||
private const DUMMY_HASH = '$2y$10$cfqH24k.mHkYnkyj5jT4k.UvY6oQzU/80pBf1K5k6v78d78f99e4K';
|
||||
|
||||
public function completeLogin(User $user): void
|
||||
{
|
||||
parent::completeLogin($user);
|
||||
|
|
@ -38,9 +44,15 @@ class LegacySafeSession extends Session
|
|||
$givenPassword = (string) $credentials['password'];
|
||||
unset($credentials['password']);
|
||||
|
||||
/** @var Passwords $passwords */
|
||||
$passwords = service('passwords');
|
||||
|
||||
$user = $this->provider->findByCredentials($credentials);
|
||||
|
||||
if ($user === null) {
|
||||
// Mitigate user enumeration timing attack by performing a dummy hash verification
|
||||
$passwords->verify($givenPassword, self::DUMMY_HASH);
|
||||
|
||||
return new Result([
|
||||
'success' => false,
|
||||
'reason' => lang('Auth.badAttempt'),
|
||||
|
|
@ -49,19 +61,18 @@ class LegacySafeSession extends Session
|
|||
|
||||
$hash = $user->password_hash ?? null;
|
||||
if (!is_string($hash) || trim($hash) === '') {
|
||||
$passwords->verify($givenPassword, self::DUMMY_HASH);
|
||||
|
||||
return new Result([
|
||||
'success' => false,
|
||||
'reason' => lang('Auth.invalidPassword'),
|
||||
'reason' => lang('Auth.badAttempt'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var Passwords $passwords */
|
||||
$passwords = service('passwords');
|
||||
|
||||
if (!$passwords->verify($givenPassword, $hash)) {
|
||||
return new Result([
|
||||
'success' => false,
|
||||
'reason' => lang('Auth.invalidPassword'),
|
||||
'reason' => lang('Auth.badAttempt'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Config;
|
||||
|
||||
use App\Filters\ApiAuthFilter;
|
||||
use App\Filters\LoginThrottleFilter;
|
||||
use App\Filters\SecureHeadersFilter;
|
||||
use App\Filters\SingleActiveSessionFilter;
|
||||
use CodeIgniter\Config\Filters as BaseFilters;
|
||||
|
|
@ -39,6 +40,7 @@ class Filters extends BaseFilters
|
|||
'performance' => PerformanceMetrics::class,
|
||||
'singleSession' => SingleActiveSessionFilter::class,
|
||||
'apiAuth' => ApiAuthFilter::class,
|
||||
'loginThrottle' => LoginThrottleFilter::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -125,5 +127,14 @@ class Filters extends BaseFilters
|
|||
*
|
||||
* @var array<string, array<string, list<string>>>
|
||||
*/
|
||||
public array $filters = [];
|
||||
public array $filters = [
|
||||
'loginThrottle' => [
|
||||
'before' => [
|
||||
'login',
|
||||
'login/*',
|
||||
'register',
|
||||
'register/*',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
116
app/Filters/LoginThrottleFilter.php
Normal file
116
app/Filters/LoginThrottleFilter.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filters;
|
||||
|
||||
use CodeIgniter\Filters\FilterInterface;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use CodeIgniter\HTTP\RequestInterface;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Authentication Endpoint Rate Limiting Filter (WASA CWE-307 Compliant)
|
||||
*
|
||||
* Enforces server-side rate limiting on authentication attempts:
|
||||
* - Restricts requests to a maximum of 5 attempts per 60 seconds per IP address.
|
||||
* - When threshold is exceeded, returns HTTP 429 Too Many Requests.
|
||||
* - Emits standard Retry-After and X-RateLimit-* headers.
|
||||
* - Logs violations for security monitoring and incident response.
|
||||
*/
|
||||
class LoginThrottleFilter implements FilterInterface
|
||||
{
|
||||
/**
|
||||
* Maximum allowed login POST requests within the time window.
|
||||
*/
|
||||
protected int $maxAttempts = 5;
|
||||
|
||||
/**
|
||||
* Time window in seconds (60 seconds = 1 minute).
|
||||
*/
|
||||
protected int $decaySeconds = 60;
|
||||
|
||||
/**
|
||||
* @param array|null $arguments
|
||||
* @return ResponseInterface|void
|
||||
*/
|
||||
public function before(RequestInterface $request, $arguments = null)
|
||||
{
|
||||
if (! $request instanceof IncomingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only throttle state-changing authentication attempts (POST)
|
||||
if (strtoupper($request->getMethod()) !== 'POST') {
|
||||
return;
|
||||
}
|
||||
|
||||
$throttler = service('throttler');
|
||||
$ip = $request->getIPAddress();
|
||||
$throttleKey = 'auth_throttle_' . md5($ip);
|
||||
|
||||
// Check if the request exceeds capacity
|
||||
if ($throttler->check($throttleKey, $this->maxAttempts, $this->decaySeconds, 1) === false) {
|
||||
$retryAfter = max(1, $throttler->getTokenTime());
|
||||
$resetTime = time() + $retryAfter;
|
||||
|
||||
// Log security warning for SIEM / audit monitoring
|
||||
log_message(
|
||||
'warning',
|
||||
"[WASA-CWE-307] Authentication rate limit exceeded. IP: {$ip}, Path: {$request->getUri()->getPath()}, Retry-After: {$retryAfter}s"
|
||||
);
|
||||
|
||||
/** @var ResponseInterface $response */
|
||||
$response = service('response');
|
||||
$response->setStatusCode(429, 'Too Many Requests');
|
||||
$response->setHeader('Retry-After', (string) $retryAfter);
|
||||
$response->setHeader('X-RateLimit-Limit', (string) $this->maxAttempts);
|
||||
$response->setHeader('X-RateLimit-Remaining', '0');
|
||||
$response->setHeader('X-RateLimit-Reset', (string) $resetTime);
|
||||
|
||||
$message = "Too many login attempts. Please wait {$retryAfter} seconds before trying again.";
|
||||
|
||||
// Return JSON for API / AJAX or JSON-accepting clients
|
||||
if ($request->isAJAX() || str_contains($request->getHeaderLine('Accept'), 'application/json')) {
|
||||
return $response->setJSON([
|
||||
'status' => 429,
|
||||
'error' => 'Too Many Requests',
|
||||
'message' => $message,
|
||||
'retry_after' => $retryAfter,
|
||||
]);
|
||||
}
|
||||
|
||||
// Return HTML response for standard browser form submissions
|
||||
$body = '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">'
|
||||
. '<title>429 Too Many Requests</title>'
|
||||
. '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
. '<style>'
|
||||
. 'body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#f8fafc;color:#1e293b;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:1rem;}'
|
||||
. '.card{background:#ffffff;border:1px solid #e2e8f0;border-radius:12px;padding:2.5rem 2rem;max-width:440px;width:100%;box-shadow:0 10px 15px -3px rgba(0,0,0,0.07);text-align:center;}'
|
||||
. '.badge{display:inline-block;padding:0.35rem 0.75rem;border-radius:9999px;background:#fee2e2;color:#991b1b;font-weight:600;font-size:0.875rem;margin-bottom:1rem;}'
|
||||
. 'h1{font-size:1.5rem;font-weight:700;color:#0f172a;margin:0 0 0.75rem 0;}'
|
||||
. 'p{color:#64748b;font-size:0.95rem;line-height:1.5;margin:0 0 1.5rem 0;}'
|
||||
. '.btn{display:inline-block;background:#0284c7;color:#ffffff;text-decoration:none;padding:0.6rem 1.25rem;border-radius:6px;font-weight:500;font-size:0.875rem;transition:background 0.15s;}'
|
||||
. '.btn:hover{background:#0369a1;}'
|
||||
. '</style>'
|
||||
. '</head><body>'
|
||||
. '<div class="card">'
|
||||
. '<span class="badge">Security Protection</span>'
|
||||
. '<h1>Too Many Attempts</h1>'
|
||||
. '<p>' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</p>'
|
||||
. '<a href="/login" class="btn">Return to Login</a>'
|
||||
. '</div>'
|
||||
. '</body></html>';
|
||||
|
||||
return $response->setBody($body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|null $arguments
|
||||
*/
|
||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void
|
||||
{
|
||||
// No action needed after request
|
||||
}
|
||||
}
|
||||
|
|
@ -3,4 +3,8 @@
|
|||
return [
|
||||
'loginTitle' => 'Login to Your Account',
|
||||
'loginSubtitle' => 'Enter your username & password to login',
|
||||
'badAttempt' => 'Unable to log you in. Please check your credentials.',
|
||||
'invalidPassword' => 'Unable to log you in. Please check your credentials.',
|
||||
'invalidUser' => 'Unable to log you in. Please check your credentials.',
|
||||
'throttled' => 'Too many requests made from this IP address. You may try again in {0} seconds.',
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user