From 367f63f4f3d54260df66c5f5e2d99f66da570989 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 21 Oct 2026 18:00:25 +0530 Subject: [PATCH] Fix WASA observations 5 and 6: user enumeration and login rate limiting --- .../Authenticators/LegacySafeSession.php | 21 +++- app/Config/Filters.php | 13 +- app/Filters/LoginThrottleFilter.php | 116 ++++++++++++++++++ app/Language/en/Auth.php | 8 +- 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 app/Filters/LoginThrottleFilter.php diff --git a/app/Authentication/Authenticators/LegacySafeSession.php b/app/Authentication/Authenticators/LegacySafeSession.php index c114eeb..8714494 100644 --- a/app/Authentication/Authenticators/LegacySafeSession.php +++ b/app/Authentication/Authenticators/LegacySafeSession.php @@ -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'), ]); } diff --git a/app/Config/Filters.php b/app/Config/Filters.php index c4bc6f9..6d04171 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -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>> */ - public array $filters = []; + public array $filters = [ + 'loginThrottle' => [ + 'before' => [ + 'login', + 'login/*', + 'register', + 'register/*', + ], + ], + ]; } diff --git a/app/Filters/LoginThrottleFilter.php b/app/Filters/LoginThrottleFilter.php new file mode 100644 index 0000000..3e1a755 --- /dev/null +++ b/app/Filters/LoginThrottleFilter.php @@ -0,0 +1,116 @@ +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 = '' + . '429 Too Many Requests' + . '' + . '' + . '' + . '
' + . 'Security Protection' + . '

Too Many Attempts

' + . '

' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '

' + . 'Return to Login' + . '
' + . ''; + + return $response->setBody($body); + } + } + + /** + * @param array|null $arguments + */ + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null): void + { + // No action needed after request + } +} diff --git a/app/Language/en/Auth.php b/app/Language/en/Auth.php index 3ea2cd4..7516225 100644 --- a/app/Language/en/Auth.php +++ b/app/Language/en/Auth.php @@ -1,6 +1,10 @@ 'Login to Your Account', - 'loginSubtitle' => 'Enter your username & password to login', + '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.', ]; \ No newline at end of file