|
@@ -147,6 +147,102 @@ final class LocalLoginTest extends AppTestCase
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ public function testDisabledLocalAdminRecordsThrottleFailure(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // SEC_REVIEW F38: hitting POST /login/local when local-admin is
|
|
|
|
|
+ // disabled returned 404 with no rate-limit. An attacker could
|
|
|
|
|
+ // burn worker threads on this URL. The throttle must accumulate
|
|
|
|
|
+ // a per-IP failure for each disabled-path hit so the existing
|
|
|
|
|
+ // 5/10/15 ladder applies even on the disabled URL.
|
|
|
|
|
+ $this->bootApp(['local_admin_enabled' => false]);
|
|
|
|
|
+ $this->request('GET', '/login');
|
|
|
|
|
+ $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
|
|
|
|
|
+
|
|
|
|
|
+ $body = http_build_query([
|
|
|
|
|
+ 'csrf_token' => $token,
|
|
|
|
|
+ 'username' => 'whatever',
|
|
|
|
|
+ 'password' => 'spam',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $response = $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/login/local',
|
|
|
|
|
+ [],
|
|
|
|
|
+ $body,
|
|
|
|
|
+ 'application/x-www-form-urlencoded',
|
|
|
|
|
+ ['REMOTE_ADDR' => '198.51.100.50'],
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ self::assertSame(404, $response->getStatusCode());
|
|
|
|
|
+ // The per-IP bucket key is `('', source_ip)` — independent of the
|
|
|
|
|
+ // attacker's submitted username so a rotating-username spray from
|
|
|
|
|
+ // one IP all lands in the same bucket.
|
|
|
|
|
+ /** @var LoginThrottle $throttle */
|
|
|
|
|
+ $throttle = $this->container->get(LoginThrottle::class);
|
|
|
|
|
+ self::assertFalse($throttle->isLocked('', '198.51.100.50'));
|
|
|
|
|
+ // Five hits should trip the lockout regardless of submitted username.
|
|
|
|
|
+ for ($i = 0; $i < 4; ++$i) {
|
|
|
|
|
+ $varied = http_build_query([
|
|
|
|
|
+ 'csrf_token' => $token,
|
|
|
|
|
+ 'username' => 'rotating-' . $i,
|
|
|
|
|
+ 'password' => 'spam',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/login/local',
|
|
|
|
|
+ [],
|
|
|
|
|
+ $varied,
|
|
|
|
|
+ 'application/x-www-form-urlencoded',
|
|
|
|
|
+ ['REMOTE_ADDR' => '198.51.100.50'],
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ self::assertTrue($throttle->isLocked('', '198.51.100.50'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function testDisabledLocalAdminLockedHitDoesNotIncrementBucket(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ // After lockout, additional hammering must not grow the bucket
|
|
|
|
|
+ // unbounded — we want the throttle file size + decode cost to be
|
|
|
|
|
+ // bounded by the lockout ladder, not by attacker request volume.
|
|
|
|
|
+ $this->bootApp(['local_admin_enabled' => false]);
|
|
|
|
|
+ $this->request('GET', '/login');
|
|
|
|
|
+ $token = (string) ($_SESSION[CsrfMiddleware::SESSION_KEY] ?? '');
|
|
|
|
|
+
|
|
|
|
|
+ $body = http_build_query([
|
|
|
|
|
+ 'csrf_token' => $token,
|
|
|
|
|
+ 'username' => 'x',
|
|
|
|
|
+ 'password' => 'x',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ for ($i = 0; $i < 5; ++$i) {
|
|
|
|
|
+ $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/login/local',
|
|
|
|
|
+ [],
|
|
|
|
|
+ $body,
|
|
|
|
|
+ 'application/x-www-form-urlencoded',
|
|
|
|
|
+ ['REMOTE_ADDR' => '198.51.100.51'],
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ /** @var LoginThrottle $throttle */
|
|
|
|
|
+ $throttle = $this->container->get(LoginThrottle::class);
|
|
|
|
|
+ self::assertTrue($throttle->isLocked('', '198.51.100.51'));
|
|
|
|
|
+ $remainingBefore = $throttle->lockoutSecondsRemaining('', '198.51.100.51');
|
|
|
|
|
+
|
|
|
|
|
+ // 50 more hits while locked — must not extend or escalate the lockout.
|
|
|
|
|
+ for ($i = 0; $i < 50; ++$i) {
|
|
|
|
|
+ $this->request(
|
|
|
|
|
+ 'POST',
|
|
|
|
|
+ '/login/local',
|
|
|
|
|
+ [],
|
|
|
|
|
+ $body,
|
|
|
|
|
+ 'application/x-www-form-urlencoded',
|
|
|
|
|
+ ['REMOTE_ADDR' => '198.51.100.51'],
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ $remainingAfter = $throttle->lockoutSecondsRemaining('', '198.51.100.51');
|
|
|
|
|
+ // Allow ±1s for clock drift across the calls.
|
|
|
|
|
+ self::assertLessThanOrEqual($remainingBefore + 1, $remainingAfter);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
public function testCsrfMissingIs403(): void
|
|
public function testCsrfMissingIs403(): void
|
|
|
{
|
|
{
|
|
|
$this->request('GET', '/login');
|
|
$this->request('GET', '/login');
|