|
|
@@ -177,6 +177,76 @@ final class LoginThrottleTest extends TestCase
|
|
|
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
|