container, 'set')) { /** @var \DI\Container $c */ $c = $this->container; $clock = FixedClock::at('2026-04-29T00:00:00Z'); $limiter = new RateLimiter($clock, 2.0, 4.0); $c->set(Clock::class, $clock); $c->set(RateLimiter::class, $limiter); /** @var ResponseFactoryInterface $rf */ $rf = $c->get(ResponseFactoryInterface::class); $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf)); $this->app = \App\App\AppFactory::build($this->container); } } public function testBurstExceedingCapacityProduces429s(): void { $reporterId = $this->createReporter('web-limited'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json']; $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode(); } $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202)); $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles. self::assertSame(4, $accepted, 'capacity-bounded successes'); self::assertSame(16, $limited, 'remainder rate-limited'); } public function testRateLimit429IncludesRetryAfter(): void { $reporterId = $this->createReporter('web-retry'); $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId); $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json']; $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null; // Drain capacity first. for ($i = 0; $i < 4; $i++) { $this->request('POST', '/api/v1/report', $headers, $body); } $resp = $this->request('POST', '/api/v1/report', $headers, $body); self::assertSame(429, $resp->getStatusCode()); self::assertSame('1', $resp->getHeaderLine('Retry-After')); } /** * SEC_REVIEW F29. The /api/v1/admin/* group previously attached only * tokenAuth → impersonation → auditContext, no rate limit. Combined * with F30 (IP-search full-table scan), F31 (deep-offset audit), and * F32 (N+1 enrichment), an authenticated Viewer (the OIDC default * role) could drive arbitrarily expensive queries at unbounded rate. * RateLimitMiddleware is now attached, so a burst against any admin * endpoint must produce 429s once the per-token bucket drains. */ public function testAdminRoutesAreRateLimited(): void { $admin = $this->createToken(TokenKind::Admin, role: Role::Admin); $headers = ['Authorization' => 'Bearer ' . $admin]; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('GET', '/api/v1/admin/me', $headers)->getStatusCode(); } $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 200)); $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429. self::assertSame(4, $accepted, 'capacity-bounded successes'); self::assertSame(16, $limited, 'remainder rate-limited'); } /** * SEC_REVIEW F29 — heavier admin endpoints (the very ones called out * by F30/F31/F32) are gated by the same per-token bucket so a Viewer * cannot pound them at unbounded rate. */ public function testAdminAuditLogIsRateLimitedPerToken(): void { $viewer = $this->createToken(TokenKind::Admin, role: Role::Viewer); $headers = ['Authorization' => 'Bearer ' . $viewer]; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('GET', '/api/v1/admin/audit-log', $headers)->getStatusCode(); } $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); self::assertGreaterThan(0, $limited, 'admin/audit-log must rate-limit'); } /** * SEC_REVIEW F14. The /api/v1/auth/* group previously had only * TokenAuth, so a service-token holder could brute-force enumerate * users via /users/{id} (F17) at unbounded rate, or burn unbounded * upsert calls. RateLimitMiddleware is now attached; a burst against * the auth endpoints must produce 429s once the bucket drains. */ public function testAuthGetUserRouteIsRateLimited(): void { $service = $this->createToken(TokenKind::Service); $headers = ['Authorization' => 'Bearer ' . $service]; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('GET', '/api/v1/auth/users/1', $headers)->getStatusCode(); } $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); self::assertGreaterThan( 0, $limited, 'auth/users/{id} must hit the rate-limit ceiling on burst (SEC_REVIEW F14)' ); // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429. self::assertSame(16, $limited, 'remainder rate-limited'); } /** * SEC_REVIEW F14. upsertLocal/upsertOidc are also gated by the same * rate limit — a leaked service token cannot pound them at unbounded * rate to amplify other findings (e.g. burning audit rows, retrying * upsert combinations). */ public function testAuthUpsertLocalRouteIsRateLimited(): void { $service = $this->createToken(TokenKind::Service); $headers = [ 'Authorization' => 'Bearer ' . $service, 'Content-Type' => 'application/json', ]; $body = json_encode(['username' => 'admin']) ?: null; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body) ->getStatusCode(); } $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); self::assertGreaterThan(0, $limited, 'upsert-local must rate-limit'); } /** * SEC_REVIEW F27. Auth-failed paths (401 from * TokenAuthenticationMiddleware) used to bypass the rate limiter * because RateLimitMiddleware sat *inside* TokenAuth. The handler * now also runs as the outermost layer, keying on `ip:` when no * principal exists, so invalid-bearer-token floods are throttled * before the DB lookup. */ public function testInvalidBearerTokenFloodIsRateLimitedBeforeAuth(): void { $headers = ['Authorization' => 'Bearer ' . str_repeat('a', 32)]; $statuses = []; for ($i = 0; $i < 20; $i++) { $statuses[] = $this->request('POST', '/api/v1/report', $headers)->getStatusCode(); } $unauthorized = count(array_filter($statuses, static fn (int $s): bool => $s === 401)); $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); // Capacity is 4, so the first 4 attempts incur a token-table lookup // and return 401; the rest drain the IP bucket and return 429. self::assertSame(4, $unauthorized, 'first 4 attempts hit the auth path'); self::assertSame(16, $limited, 'remaining attempts must 429 before auth'); } public function testMissingBearerHeaderIsAlsoRateLimitedByIp(): void { // No Authorization header at all — same protection applies, the // request must not reach TokenAuth on every retry. $statuses = []; for ($i = 0; $i < 10; $i++) { $statuses[] = $this->request('GET', '/api/v1/blocklist')->getStatusCode(); } $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429)); self::assertGreaterThan(0, $limited, 'pre-auth flood must hit 429'); } }