1
0

RateLimitTest.php 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Public;
  4. use App\Domain\Auth\Role;
  5. use App\Domain\Auth\TokenKind;
  6. use App\Domain\Time\Clock;
  7. use App\Domain\Time\FixedClock;
  8. use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
  9. use App\Infrastructure\Http\RateLimiter;
  10. use App\Tests\Integration\Support\AppTestCase;
  11. use Psr\Http\Message\ResponseFactoryInterface;
  12. /**
  13. * Override the AppTestCase to inject a tight rate limit and a fixed clock,
  14. * then run a burst that exceeds capacity (rate × 2).
  15. */
  16. final class RateLimitTest extends AppTestCase
  17. {
  18. protected function setUp(): void
  19. {
  20. parent::setUp();
  21. // Replace the clock + limiter with a tight, fixed-time pair: refill=2/s,
  22. // capacity=4. A burst of 20 must produce some 429s. We must also rebuild
  23. // the RateLimitMiddleware singleton — PHP-DI caches the constructed
  24. // instance, which already holds the wide-open limiter from setup.
  25. if (method_exists($this->container, 'set')) {
  26. /** @var \DI\Container $c */
  27. $c = $this->container;
  28. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  29. $limiter = new RateLimiter($clock, 2.0, 4.0);
  30. $c->set(Clock::class, $clock);
  31. $c->set(RateLimiter::class, $limiter);
  32. /** @var ResponseFactoryInterface $rf */
  33. $rf = $c->get(ResponseFactoryInterface::class);
  34. $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf));
  35. $this->app = \App\App\AppFactory::build($this->container);
  36. }
  37. }
  38. public function testBurstExceedingCapacityProduces429s(): void
  39. {
  40. $reporterId = $this->createReporter('web-limited');
  41. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  42. $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
  43. $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
  44. $statuses = [];
  45. for ($i = 0; $i < 20; $i++) {
  46. $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode();
  47. }
  48. $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202));
  49. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  50. // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles.
  51. self::assertSame(4, $accepted, 'capacity-bounded successes');
  52. self::assertSame(16, $limited, 'remainder rate-limited');
  53. }
  54. public function testRateLimit429IncludesRetryAfter(): void
  55. {
  56. $reporterId = $this->createReporter('web-retry');
  57. $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
  58. $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
  59. $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
  60. // Drain capacity first.
  61. for ($i = 0; $i < 4; $i++) {
  62. $this->request('POST', '/api/v1/report', $headers, $body);
  63. }
  64. $resp = $this->request('POST', '/api/v1/report', $headers, $body);
  65. self::assertSame(429, $resp->getStatusCode());
  66. self::assertSame('1', $resp->getHeaderLine('Retry-After'));
  67. }
  68. /**
  69. * SEC_REVIEW F29. The /api/v1/admin/* group previously attached only
  70. * tokenAuth → impersonation → auditContext, no rate limit. Combined
  71. * with F30 (IP-search full-table scan), F31 (deep-offset audit), and
  72. * F32 (N+1 enrichment), an authenticated Viewer (the OIDC default
  73. * role) could drive arbitrarily expensive queries at unbounded rate.
  74. * RateLimitMiddleware is now attached, so a burst against any admin
  75. * endpoint must produce 429s once the per-token bucket drains.
  76. */
  77. public function testAdminRoutesAreRateLimited(): void
  78. {
  79. $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
  80. $headers = ['Authorization' => 'Bearer ' . $admin];
  81. $statuses = [];
  82. for ($i = 0; $i < 20; $i++) {
  83. $statuses[] = $this->request('GET', '/api/v1/admin/me', $headers)->getStatusCode();
  84. }
  85. $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 200));
  86. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  87. // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429.
  88. self::assertSame(4, $accepted, 'capacity-bounded successes');
  89. self::assertSame(16, $limited, 'remainder rate-limited');
  90. }
  91. /**
  92. * SEC_REVIEW F29 — heavier admin endpoints (the very ones called out
  93. * by F30/F31/F32) are gated by the same per-token bucket so a Viewer
  94. * cannot pound them at unbounded rate.
  95. */
  96. public function testAdminAuditLogIsRateLimitedPerToken(): void
  97. {
  98. $viewer = $this->createToken(TokenKind::Admin, role: Role::Viewer);
  99. $headers = ['Authorization' => 'Bearer ' . $viewer];
  100. $statuses = [];
  101. for ($i = 0; $i < 20; $i++) {
  102. $statuses[] = $this->request('GET', '/api/v1/admin/audit-log', $headers)->getStatusCode();
  103. }
  104. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  105. self::assertGreaterThan(0, $limited, 'admin/audit-log must rate-limit');
  106. }
  107. /**
  108. * SEC_REVIEW F14. The /api/v1/auth/* group previously had only
  109. * TokenAuth, so a service-token holder could brute-force enumerate
  110. * users via /users/{id} (F17) at unbounded rate, or burn unbounded
  111. * upsert calls. RateLimitMiddleware is now attached; a burst against
  112. * the auth endpoints must produce 429s once the bucket drains.
  113. */
  114. public function testAuthGetUserRouteIsRateLimited(): void
  115. {
  116. $service = $this->createToken(TokenKind::Service);
  117. $headers = ['Authorization' => 'Bearer ' . $service];
  118. $statuses = [];
  119. for ($i = 0; $i < 20; $i++) {
  120. $statuses[] = $this->request('GET', '/api/v1/auth/users/1', $headers)->getStatusCode();
  121. }
  122. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  123. self::assertGreaterThan(
  124. 0,
  125. $limited,
  126. 'auth/users/{id} must hit the rate-limit ceiling on burst (SEC_REVIEW F14)'
  127. );
  128. // Capacity is 4 (refill=2, capacity=4 from setUp); the rest must 429.
  129. self::assertSame(16, $limited, 'remainder rate-limited');
  130. }
  131. /**
  132. * SEC_REVIEW F14. upsertLocal/upsertOidc are also gated by the same
  133. * rate limit — a leaked service token cannot pound them at unbounded
  134. * rate to amplify other findings (e.g. burning audit rows, retrying
  135. * upsert combinations).
  136. */
  137. public function testAuthUpsertLocalRouteIsRateLimited(): void
  138. {
  139. $service = $this->createToken(TokenKind::Service);
  140. $headers = [
  141. 'Authorization' => 'Bearer ' . $service,
  142. 'Content-Type' => 'application/json',
  143. ];
  144. $body = json_encode(['username' => 'admin']) ?: null;
  145. $statuses = [];
  146. for ($i = 0; $i < 20; $i++) {
  147. $statuses[] = $this->request('POST', '/api/v1/auth/users/upsert-local', $headers, $body)
  148. ->getStatusCode();
  149. }
  150. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  151. self::assertGreaterThan(0, $limited, 'upsert-local must rate-limit');
  152. }
  153. /**
  154. * SEC_REVIEW F27. Auth-failed paths (401 from
  155. * TokenAuthenticationMiddleware) used to bypass the rate limiter
  156. * because RateLimitMiddleware sat *inside* TokenAuth. The handler
  157. * now also runs as the outermost layer, keying on `ip:` when no
  158. * principal exists, so invalid-bearer-token floods are throttled
  159. * before the DB lookup.
  160. */
  161. public function testInvalidBearerTokenFloodIsRateLimitedBeforeAuth(): void
  162. {
  163. $headers = ['Authorization' => 'Bearer ' . str_repeat('a', 32)];
  164. $statuses = [];
  165. for ($i = 0; $i < 20; $i++) {
  166. $statuses[] = $this->request('POST', '/api/v1/report', $headers)->getStatusCode();
  167. }
  168. $unauthorized = count(array_filter($statuses, static fn (int $s): bool => $s === 401));
  169. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  170. // Capacity is 4, so the first 4 attempts incur a token-table lookup
  171. // and return 401; the rest drain the IP bucket and return 429.
  172. self::assertSame(4, $unauthorized, 'first 4 attempts hit the auth path');
  173. self::assertSame(16, $limited, 'remaining attempts must 429 before auth');
  174. }
  175. public function testMissingBearerHeaderIsAlsoRateLimitedByIp(): void
  176. {
  177. // No Authorization header at all — same protection applies, the
  178. // request must not reach TokenAuth on every retry.
  179. $statuses = [];
  180. for ($i = 0; $i < 10; $i++) {
  181. $statuses[] = $this->request('GET', '/api/v1/blocklist')->getStatusCode();
  182. }
  183. $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
  184. self::assertGreaterThan(0, $limited, 'pre-auth flood must hit 429');
  185. }
  186. }