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
|
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
|
public function completeLogin(User $user): void
|
||||||
{
|
{
|
||||||
parent::completeLogin($user);
|
parent::completeLogin($user);
|
||||||
|
|
@ -38,9 +44,15 @@ class LegacySafeSession extends Session
|
||||||
$givenPassword = (string) $credentials['password'];
|
$givenPassword = (string) $credentials['password'];
|
||||||
unset($credentials['password']);
|
unset($credentials['password']);
|
||||||
|
|
||||||
|
/** @var Passwords $passwords */
|
||||||
|
$passwords = service('passwords');
|
||||||
|
|
||||||
$user = $this->provider->findByCredentials($credentials);
|
$user = $this->provider->findByCredentials($credentials);
|
||||||
|
|
||||||
if ($user === null) {
|
if ($user === null) {
|
||||||
|
// Mitigate user enumeration timing attack by performing a dummy hash verification
|
||||||
|
$passwords->verify($givenPassword, self::DUMMY_HASH);
|
||||||
|
|
||||||
return new Result([
|
return new Result([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'reason' => lang('Auth.badAttempt'),
|
'reason' => lang('Auth.badAttempt'),
|
||||||
|
|
@ -49,19 +61,18 @@ class LegacySafeSession extends Session
|
||||||
|
|
||||||
$hash = $user->password_hash ?? null;
|
$hash = $user->password_hash ?? null;
|
||||||
if (!is_string($hash) || trim($hash) === '') {
|
if (!is_string($hash) || trim($hash) === '') {
|
||||||
|
$passwords->verify($givenPassword, self::DUMMY_HASH);
|
||||||
|
|
||||||
return new Result([
|
return new Result([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'reason' => lang('Auth.invalidPassword'),
|
'reason' => lang('Auth.badAttempt'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Passwords $passwords */
|
|
||||||
$passwords = service('passwords');
|
|
||||||
|
|
||||||
if (!$passwords->verify($givenPassword, $hash)) {
|
if (!$passwords->verify($givenPassword, $hash)) {
|
||||||
return new Result([
|
return new Result([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'reason' => lang('Auth.invalidPassword'),
|
'reason' => lang('Auth.badAttempt'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace Config;
|
namespace Config;
|
||||||
|
|
||||||
use App\Filters\ApiAuthFilter;
|
use App\Filters\ApiAuthFilter;
|
||||||
|
use App\Filters\LoginThrottleFilter;
|
||||||
use App\Filters\SecureHeadersFilter;
|
use App\Filters\SecureHeadersFilter;
|
||||||
use App\Filters\SingleActiveSessionFilter;
|
use App\Filters\SingleActiveSessionFilter;
|
||||||
use CodeIgniter\Config\Filters as BaseFilters;
|
use CodeIgniter\Config\Filters as BaseFilters;
|
||||||
|
|
@ -39,6 +40,7 @@ class Filters extends BaseFilters
|
||||||
'performance' => PerformanceMetrics::class,
|
'performance' => PerformanceMetrics::class,
|
||||||
'singleSession' => SingleActiveSessionFilter::class,
|
'singleSession' => SingleActiveSessionFilter::class,
|
||||||
'apiAuth' => ApiAuthFilter::class,
|
'apiAuth' => ApiAuthFilter::class,
|
||||||
|
'loginThrottle' => LoginThrottleFilter::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -125,5 +127,14 @@ class Filters extends BaseFilters
|
||||||
*
|
*
|
||||||
* @var array<string, array<string, list<string>>>
|
* @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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'loginTitle' => 'Login to Your Account',
|
'loginTitle' => 'Login to Your Account',
|
||||||
'loginSubtitle' => 'Enter your username & password to login',
|
'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