throttle(); for ($i = 0; $i < 4; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertFalse($t->isLocked('admin', '10.0.0.1')); } public function testFifthFailureLocksForOneMinute(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); self::assertGreaterThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); self::assertLessThanOrEqual(60, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testTenthFailureLocksForFiveMinutes(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 10; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testFifteenthFailureLocksForThirtyMinutes(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 15; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testLockoutExpiresAfterTimeAdvance(): void { $now = 1000000; $t = new LoginThrottle($this->logger(), function () use (&$now): int { return $now; }); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); $now += 61; self::assertFalse($t->isLocked('admin', '10.0.0.1')); } public function testDifferentIpHasIndependentBucket(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } self::assertTrue($t->isLocked('admin', '10.0.0.1')); self::assertFalse($t->isLocked('admin', '10.0.0.2')); } public function testClearResetsBucket(): void { $t = $this->throttle(); for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } $t->clear('admin', '10.0.0.1'); self::assertFalse($t->isLocked('admin', '10.0.0.1')); self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testUsernameCaseDoesNotMultiplyBuckets(): void { $t = $this->throttle(); for ($i = 0; $i < 3; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } for ($i = 0; $i < 2; ++$i) { $t->recordFailure('ADMIN', '10.0.0.1'); } self::assertTrue($t->isLocked('Admin', '10.0.0.1')); } public function testPerUsernameBucketLocksOutAcrossDistinctIps(): void { $t = $this->throttle(); // 24 failures from 24 distinct IPs — each is a fresh per-IP bucket // (only 1 attempt apiece), but the per-username counter accumulates. for ($i = 0; $i < 24; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertFalse($t->isLocked('admin', '10.0.0.99')); // 25th attempt from yet another IP trips the per-username lockout. $t->recordFailure('admin', '10.0.0.99'); self::assertTrue($t->isLocked('admin', '10.0.1.1')); } public function testPerUsernameLadderProgresses(): void { $now = 1000000; $t = $this->throttle($now); for ($i = 0; $i < 25; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(60, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); for ($i = 25; $i < 50; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); for ($i = 50; $i < 100; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertSame(1800, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); } public function testLockoutSecondsRemainingReturnsLargerOfBuckets(): void { $now = 1000000; $t = $this->throttle($now); // 5 failures from one IP → per-IP locked for 60s. for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } // Plus 45 more from distinct IPs → per-username at count=50 → 300s. for ($i = 0; $i < 45; ++$i) { $t->recordFailure('admin', '10.1.0.' . $i); } // Asked from yet-another-IP, only the per-username lock applies — 300s. self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); // Asked from the per-IP bucket's IP, 300s still wins (max of 60/300). self::assertSame(300, $t->lockoutSecondsRemaining('admin', '10.0.0.1')); } public function testClearResetsBothBuckets(): void { $t = $this->throttle(); // Build per-username pressure. for ($i = 0; $i < 25; ++$i) { $t->recordFailure('admin', '10.0.0.' . $i); } self::assertTrue($t->isLocked('admin', '10.99.0.1')); $t->clear('admin', '10.0.0.0'); self::assertFalse($t->isLocked('admin', '10.99.0.1')); self::assertSame(0, $t->lockoutSecondsRemaining('admin', '10.99.0.1')); } public function testEmptyUsernameStillBucketsPerUsername(): void { $t = $this->throttle(); for ($i = 0; $i < 25; ++$i) { $t->recordFailure('', '10.0.0.' . $i); } self::assertTrue($t->isLocked('', '10.99.0.1')); } public function testRecordFailureLogRateIsCappedToFirstWarningPerBucket(): void { // SEC_REVIEW F74: a sustained brute-force attack must not // pour one structured log line per failed attempt into the // disk/SIEM pipeline. The throttle now emits a single // `warning` per (username, ip) bucket — at the first // failure — and stays silent until a lockout fires (which // emits an `error`). The lockout's `failure_count` field // captures the burst size, so total visibility is preserved // without per-attempt spam. $handler = new TestHandler(); $logger = new Logger('test'); $logger->pushHandler($handler); $t = new LoginThrottle($logger); // Drive 5 failures: counts 1, 2, 3, 4, 5. Lockout fires at // 5. Expected log emissions: 1 warning (count=1) + 1 error // (count=5 lockout) = 2 lines, NOT 5. for ($i = 0; $i < 5; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } $warnings = array_filter( $handler->getRecords(), static fn ($r) => $r->level->name === 'Warning', ); $errors = array_filter( $handler->getRecords(), static fn ($r) => $r->level->name === 'Error', ); self::assertCount(1, $warnings, 'expected a single warning at the first failure'); self::assertGreaterThanOrEqual(1, count($errors), 'expected at least one lockout error'); // The single warning must record `failure_count = 1`. /** @var \Monolog\LogRecord $w */ $w = array_values($warnings)[0]; self::assertSame(1, $w->context['failure_count']); } public function testHighRateBruteForceDoesNotSpamPerFailureWarnings(): void { // 100 attempts from one IP for one username: pre-fix would // have emitted ~4 warnings per bucket (counts 1..4) before // lockout. With the rate cap, exactly 1 warning fires. // Subsequent attempts past the lockout are not recorded // (the controller-side `isLocked` check skips // `recordFailure`), so the test models the bucket-internal // behaviour by calling `recordFailure` directly 100 times — // we want to see the cap survive even if a future change // slipped past the controller-level guard. $handler = new TestHandler(); $logger = new Logger('test'); $logger->pushHandler($handler); $t = new LoginThrottle($logger); for ($i = 0; $i < 100; ++$i) { $t->recordFailure('admin', '10.0.0.1'); } $warnings = array_filter( $handler->getRecords(), static fn ($r) => $r->level->name === 'Warning', ); // No matter how many failures we drive into the same bucket, // we get a single warning. The error count is bounded by // the lockout ladder (5 / 10 / 15 IP-bucket triggers and // 25 / 50 / 100 username-bucket triggers). self::assertCount(1, $warnings); } public function testRecordFailureLogsFingerprintsNotRawIdentifiers(): void { // SEC_REVIEW F34: a SIEM operator reading these logs must not see // the attempted username (which can be a password typed in the // wrong field) or the raw client IP. They must see a stable // fingerprint instead so triage can still correlate. $handler = new TestHandler(); $logger = new Logger('test'); $logger->pushHandler($handler); $t = new LoginThrottle($logger); $rawUser = 'hunter2-typed-into-username-field'; $rawIp = '198.51.100.77'; for ($i = 0; $i < 5; ++$i) { $t->recordFailure($rawUser, $rawIp); } self::assertNotEmpty($handler->getRecords(), 'expected lockout/failure events to be logged'); foreach ($handler->getRecords() as $record) { $serialized = json_encode([ 'message' => $record->message, 'context' => $record->context, ], \JSON_THROW_ON_ERROR); self::assertStringNotContainsString($rawUser, $serialized); self::assertStringNotContainsString($rawIp, $serialized); self::assertArrayHasKey('username_fp', $record->context); self::assertArrayHasKey('source_ip_fp', $record->context); self::assertArrayNotHasKey('username', $record->context); self::assertArrayNotHasKey('source_ip', $record->context); } } private function throttle(?int $fixedTime = null): LoginThrottle { if ($fixedTime === null) { return new LoginThrottle($this->logger()); } return new LoginThrottle($this->logger(), static fn (): int => $fixedTime); } private function logger(): LoggerInterface { $l = new Logger('test'); $l->pushHandler(new NullHandler()); return $l; } }