Skip to content

Commit 485f01f

Browse files
CyberSecDefclaude
andcommitted
feat: per-IP rate limiting on the public /api (120/min sliding window)
After a recent crawl pulled every API requirement sequentially from two datacenter IPs, add Symfony RateLimiter throttling for /api. - symfony/rate-limiter 7.4.* added (vendored via composer.lock) - config/packages/rate_limiter.yaml: 'api' limiter, sliding_window, 120 req/min per IP, backed by cache.app (filesystem), no lock dep (single VPS) - ApiRateLimitSubscriber: enforces it on /api at kernel.request, returns 429 + Retry-After when exceeded; exempts loopback (health checks, internal calls, functional-test client) - ApiRateLimitTest: asserts /api 429s past the limit and non-/api never throttles Full suite green on PHP 8.4 (200/200). Deploy: composer install + cache:clear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 164b009 commit 485f01f

5 files changed

Lines changed: 219 additions & 4 deletions

File tree

cyber.trackr.live/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"symfony/process": "7.4.*",
2828
"symfony/property-access": "7.4.*",
2929
"symfony/property-info": "7.4.*",
30+
"symfony/rate-limiter": "7.4.*",
3031
"symfony/runtime": "7.4.*",
3132
"symfony/security-bundle": "7.4.*",
3233
"symfony/serializer": "7.4.*",

cyber.trackr.live/composer.lock

Lines changed: 78 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
framework:
2+
rate_limiter:
3+
# Per-client-IP limit for the public JSON API. 120 req/min sustained
4+
# (~2/sec) is invisible to humans and normal API clients but throttles
5+
# bulk crawlers hard. Enforced by App\EventSubscriber\ApiRateLimitSubscriber.
6+
api:
7+
policy: 'sliding_window'
8+
limit: 120
9+
interval: '1 minute'
10+
# Single-VPS deployment: use the filesystem app cache for counters,
11+
# and no lock factory (avoids a hard dep on symfony/lock; the slight
12+
# imprecision under concurrency is irrelevant at this scale).
13+
cache_pool: 'cache.app'
14+
lock_factory: null
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace App\EventSubscriber;
4+
5+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
6+
use Symfony\Component\HttpKernel\Event\RequestEvent;
7+
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
8+
use Symfony\Component\HttpKernel\KernelEvents;
9+
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;
10+
11+
/**
12+
* Per-IP rate limiting for the public JSON API.
13+
*
14+
* Applies the 'api' limiter (120 req/min/IP, sliding window — see
15+
* config/packages/rate_limiter.yaml) to every /api request. Invisible to humans
16+
* and normal API clients; throttles bulk crawlers. Returns 429 + Retry-After
17+
* when the window is exhausted.
18+
*
19+
* Keyed by client IP, which is accurate on DreamHost where Apache serves PHP
20+
* directly. If a CDN/reverse proxy is ever placed in front, configure
21+
* framework.trusted_proxies so getClientIp() reads the forwarded address rather
22+
* than the proxy's (otherwise every visitor shares one bucket).
23+
*/
24+
final class ApiRateLimitSubscriber implements EventSubscriberInterface
25+
{
26+
public function __construct(
27+
private readonly RateLimiterFactoryInterface $apiLimiter,
28+
) {
29+
}
30+
31+
public function onKernelRequest(RequestEvent $event): void
32+
{
33+
if (!$event->isMainRequest()) {
34+
return;
35+
}
36+
37+
$request = $event->getRequest();
38+
if (!str_starts_with($request->getPathInfo(), '/api')) {
39+
return;
40+
}
41+
42+
// Never throttle loopback — the server's own internal calls, health
43+
// checks, and the functional-test client all originate from here.
44+
$ip = $request->getClientIp();
45+
if (null === $ip || '127.0.0.1' === $ip || '::1' === $ip) {
46+
return;
47+
}
48+
49+
$limit = $this->apiLimiter->create($ip)->consume(1);
50+
if (!$limit->isAccepted()) {
51+
$retryAfter = max(1, $limit->getRetryAfter()->getTimestamp() - time());
52+
throw new TooManyRequestsHttpException($retryAfter, 'API rate limit exceeded — slow down.');
53+
}
54+
}
55+
56+
public static function getSubscribedEvents(): array
57+
{
58+
// Run early, before routing/controller work, so throttled requests are
59+
// cheap to reject.
60+
return [KernelEvents::REQUEST => [['onKernelRequest', 100]]];
61+
}
62+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace App\Tests\EventSubscriber;
4+
5+
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
6+
7+
class ApiRateLimitTest extends WebTestCase
8+
{
9+
/**
10+
* After the configured per-IP limit (120/min) is exceeded, /api requests
11+
* must return 429 with a Retry-After header.
12+
*/
13+
public function testApiIsRateLimitedPerIp(): void
14+
{
15+
$client = static::createClient();
16+
$client->disableReboot(); // keep one kernel so the flood is fast
17+
$ip = '203.0.113.55';
18+
19+
// Deterministic start: clear this synthetic IP's window so prior runs
20+
// (within the same minute) can't affect the result.
21+
static::getContainer()->get('limiter.api')->create($ip)->reset();
22+
23+
$server = ['REMOTE_ADDR' => $ip];
24+
25+
// First request must pass.
26+
$client->request('GET', '/api', [], [], $server);
27+
$this->assertResponseIsSuccessful();
28+
29+
// Within the next ~120 it must start returning 429.
30+
$got429 = false;
31+
for ($i = 0; $i < 200; $i++) {
32+
$client->request('GET', '/api', [], [], $server);
33+
if (429 === $client->getResponse()->getStatusCode()) {
34+
$got429 = true;
35+
$this->assertNotEmpty(
36+
$client->getResponse()->headers->get('Retry-After'),
37+
'429 response should carry a Retry-After header'
38+
);
39+
break;
40+
}
41+
}
42+
$this->assertTrue($got429, 'API should return 429 once the rate limit is exceeded');
43+
}
44+
45+
/**
46+
* Non-/api routes must never be throttled, even when the same IP has
47+
* exhausted its API budget.
48+
*/
49+
public function testNonApiPathIsNotRateLimited(): void
50+
{
51+
$client = static::createClient();
52+
$ip = '203.0.113.56';
53+
54+
// Drain this IP's API limiter directly, then prove a normal page still loads.
55+
$limiter = static::getContainer()->get('limiter.api')->create($ip);
56+
$limiter->reset();
57+
for ($i = 0; $i < 121; $i++) {
58+
$limiter->consume(1);
59+
}
60+
61+
$client->request('GET', '/', [], [], ['REMOTE_ADDR' => $ip]);
62+
$this->assertResponseIsSuccessful();
63+
}
64+
}

0 commit comments

Comments
 (0)