diff --git a/backend/config/packages/rate_limiter.php b/backend/config/packages/rate_limiter.php deleted file mode 100644 index d6b17532..00000000 --- a/backend/config/packages/rate_limiter.php +++ /dev/null @@ -1,13 +0,0 @@ -rateLimiter() - ->limiter('public_api') - ->policy('fixed_window') - ->limit(30) - ->interval('1 minute'); -}; diff --git a/backend/config/routes/routes.php b/backend/config/routes/routes.php index 2723e609..b34c7521 100644 --- a/backend/config/routes/routes.php +++ b/backend/config/routes/routes.php @@ -19,13 +19,15 @@ ->prefix('/api/sudo') ->namePrefix('api_sudo_'); + $routes->import('../../src/Api/Machine/Controller', 'attribute') + ->prefix('/api/machine') + ->namePrefix('api_machine_'); + // root API $routes->import('../../src/Api/Root', 'attribute') ->prefix('/api') ->namePrefix('api_root_'); - - // local API (dev and test only) $routes->import('../../src/Api/Local', 'attribute') ->prefix('/api/local') ->condition('env("APP_ENV") in ["dev", "test"]') diff --git a/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php b/backend/src/Api/Machine/Controller/Integration/Relay/RelayWebhookController.php similarity index 98% rename from backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php rename to backend/src/Api/Machine/Controller/Integration/Relay/RelayWebhookController.php index 5f81ec03..de5c9528 100644 --- a/backend/src/Api/Public/Controller/Integration/Relay/RelayWebhookController.php +++ b/backend/src/Api/Machine/Controller/Integration/Relay/RelayWebhookController.php @@ -1,6 +1,6 @@ getPathInfo(), '/api/public'); - } - - public function onKernelRequest(RequestEvent $event): void - { - - $request = $event->getRequest(); - - if (!$this->isPublicApiRequest($request)) { - return; - } - - $limiter = $this->publicApiLimiter->create($request->getClientIp()); - $limit = $limiter->consume(); - - $headers = [ - 'RateLimit-Limit' => $limit->getLimit(), - 'RateLimit-Remaining' => $limit->getRemainingTokens(), - 'RateLimit-Reset' => $limit->getRetryAfter()->getTimestamp() - time(), - ]; - - $request->attributes->set(self::RATE_LIMIT_HEADERS_KEY, $headers); - - if ($limit->isAccepted() === false) { - $response = new Response( - null, - Response::HTTP_TOO_MANY_REQUESTS, - $headers - ); - $event->setResponse($response); - } - - } - - public function onKernelResponse(ResponseEvent $event): void - { - $request = $event->getRequest(); - $response = $event->getResponse(); - - if (!$this->isPublicApiRequest($request)) { - return; - } - - if ($request->attributes->has(self::RATE_LIMIT_HEADERS_KEY)) { - /** @var array $headers */ - $headers = $request->attributes->get(self::RATE_LIMIT_HEADERS_KEY); - foreach ($headers as $key => $value) { - $response->headers->set($key, $value); - } - } - } - -} diff --git a/backend/src/Api/RateLimit/RateLimit.php b/backend/src/Api/RateLimit/RateLimit.php new file mode 100644 index 00000000..8d4cd824 --- /dev/null +++ b/backend/src/Api/RateLimit/RateLimit.php @@ -0,0 +1,98 @@ +isDev = $this->env === 'dev'; + } + + /** + * Rate limit for a user session. + * 60 per minute + * @return RateLimitConfig + */ + public function session(): array + { + return [ + 'id' => 'console_api_session', + 'policy' => 'fixed_window', + 'limit' => $this->isDev ? 1000 : 60, + 'interval' => '1 minute', + ]; + } + + /** + * Rate limit for an API key. + * 100 per minute + * @return RateLimitConfig + */ + public function apiKey(): array + { + return [ + 'id' => 'console_api_api_key', + 'policy' => 'fixed_window', + 'limit' => $this->isDev ? 1000 : 100, + 'interval' => '1 minute', + ]; + } + + /** + * Rate limit for public API per IP. + * 30 per minute per IP + * @return RateLimitConfig + */ + public function publicApi(): array + { + return [ + 'id' => 'public_api', + 'policy' => 'fixed_window', + 'limit' => $this->isDev ? 1000 : 30, + 'interval' => '1 minute', + ]; + } + + /** + * Rate limit for the POST /subscribers endpoint. + * 1 subscribe per email per minute + * @return RateLimitConfig + */ + public function subscriberPerEmailPerMinute(): array + { + return [ + 'id' => 'public_api_subscriber_per_minute', + 'policy' => 'fixed_window', + 'limit' => 2, + 'interval' => '1 minute', + ]; + } + + /** + * Rate limit for the POST /subscribers endpoint. + * 6 subscribes per email per hour + * @return RateLimitConfig + */ + public function subscriberPerEmailPerHour(): array + { + return [ + 'id' => 'public_api_subscriber_per_hour', + 'policy' => 'fixed_window', + 'limit' => 6, + 'interval' => '1 hour', + ]; + } + +} diff --git a/backend/src/Api/RateLimit/RateLimitListener.php b/backend/src/Api/RateLimit/RateLimitListener.php new file mode 100644 index 00000000..d3f5c5be --- /dev/null +++ b/backend/src/Api/RateLimit/RateLimitListener.php @@ -0,0 +1,213 @@ +getPathInfo(), '/api/public'); + } + + private function isConsoleApiRequest(Request $request): bool + { + return str_starts_with($request->getPathInfo(), '/api/console'); + } + + private function isMachineApiRequest(Request $request): bool + { + return str_starts_with($request->getPathInfo(), '/api/machine'); + } + + private function isSubscribePostRequest(Request $request): bool + { + return $request->getMethod() === 'POST' + && $request->getPathInfo() === '/api/public/form/subscribe'; + } + + private function getRateLimiter(Request $request): LimiterInterface + { + // check if this is a session request (user logged in) + if (AuthorizationListener::hasUser($request)) { + $user = AuthorizationListener::getUser($request); + return $this->rateLimiterProvider->rateLimiter($this->rateLimit->session(), "user:" . $user->id); + } + + $apiKey = AuthorizationListener::getApiKey($request); + + return $this->rateLimiterProvider->rateLimiter($this->rateLimit->apiKey(), 'api_key:' . $apiKey->getId()); + } + + + /** + * @param array{id: string, policy: string, limit: int, interval: string} $rateLimitConfig + * @param string $identifier + * @param string $errorMessage Message with %d placeholder for resetIn seconds + * @return void + */ + private function checkRateLimit(array $rateLimitConfig, string $identifier, string $errorMessage): void + { + $limiter = $this->rateLimiterProvider->rateLimiter($rateLimitConfig, $identifier); + $limit = $limiter->consume(); + + if ($limit->isAccepted() === false) { + $resetIn = max($limit->getRetryAfter()->getTimestamp() - time(), 0); + throw new TooManyRequestsHttpException( + message: sprintf($errorMessage, $resetIn), + ); + } + } + + public function onController(ControllerEvent $controllerEvent): void + { + if ($controllerEvent->isMainRequest() === false) { + return; + } + + $request = $controllerEvent->getRequest(); + + // Unified rate limiting logic + if ($this->isPublicApiRequest($request)) { + if ($this->isSubscribePostRequest($request)) { + $this->applySubscribeRateLimit($request); + } + + $this->applyPublicApiRateLimit($request); + + } else if ($this->isConsoleApiRequest($request)) { + $this->applyConsoleApiRateLimit($request); + + } else if ($this->isMachineApiRequest($request)) { + // Machine API - no rate limiting + return; + } + // Other API endpoints - no rate limiting + } + + private function applyPublicApiRateLimit(Request $request): void + { + $limiter = $this->rateLimiterProvider->rateLimiter( + $this->rateLimit->publicApi(), + 'public_ip:' . $request->getClientIp() + ); + $limit = $limiter->consume(); + + $resetIn = max($limit->getRetryAfter()->getTimestamp() - time(), 0); + $request->attributes->set(self::PUBLIC_RATE_LIMIT_HEADERS_KEY, [ + 'RateLimit-Limit' => $limit->getLimit(), + 'RateLimit-Remaining' => $limit->getRemainingTokens(), + 'RateLimit-Reset' => $resetIn, + ]); + + if ($limit->isAccepted() === false) { + throw new TooManyRequestsHttpException( + message: 'Rate limit exceeded. Please try again in ' . $resetIn . ' seconds.', + ); + } + } + + private function applyConsoleApiRateLimit(Request $request): void + { + $limiter = $this->getRateLimiter($request); + $limit = $limiter->consume(); + + $resetIn = max($limit->getRetryAfter()->getTimestamp() - time(), 0); + $request->attributes->set(self::CONSOLE_RATE_LIMIT_HEADERS_KEY, [ + 'X-RateLimit-Limit' => $limit->getLimit(), + 'X-RateLimit-Remaining' => $limit->getRemainingTokens(), + 'X-RateLimit-Reset' => $resetIn, + ]); + + if ($limit->isAccepted() === false) { + throw new TooManyRequestsHttpException( + message: 'Rate limit exceeded. Please try again later in ' . $resetIn . ' seconds.', + ); + } + } + + private function applySubscribeRateLimit(Request $request): void + { + $content = $request->getContent(); + $data = json_decode($content, true); + + if (!is_array($data)) { + return; + } + + if (!isset($data['email']) || !is_string($data['email'])) { + return; // Skip rate limiting if email is not provided or invalid + } + + $email = $data['email']; + $identifier = 'subscriber_email:' . $email; + + $this->checkRateLimit( + $this->rateLimit->subscriberPerEmailPerMinute(), + $identifier, + 'You have recently requested a subscription confirm link. Please try again in %d seconds.' + ); + + $this->checkRateLimit( + $this->rateLimit->subscriberPerEmailPerHour(), + $identifier, + 'You have recently requested a subscription confirm link. Please try again in %d seconds.' + ); + } + + public function onResponse(ResponseEvent $responseEvent): void + { + if ($responseEvent->isMainRequest() === false) { + return; + } + + $request = $responseEvent->getRequest(); + $response = $responseEvent->getResponse(); + + // Add rate limit headers for Console API + if ($this->isConsoleApiRequest($request)) { + if ($request->attributes->has(self::CONSOLE_RATE_LIMIT_HEADERS_KEY)) { + /** @var array $rateLimitHeaders */ + $rateLimitHeaders = $request->attributes->get(self::CONSOLE_RATE_LIMIT_HEADERS_KEY); + foreach ($rateLimitHeaders as $header => $value) { + $response->headers->set($header, (string)$value); + } + } + } + + // Add rate limit headers for Public API + if ($this->isPublicApiRequest($request)) { + if ($request->attributes->has(self::PUBLIC_RATE_LIMIT_HEADERS_KEY)) { + /** @var array $rateLimitHeaders */ + $rateLimitHeaders = $request->attributes->get(self::PUBLIC_RATE_LIMIT_HEADERS_KEY); + foreach ($rateLimitHeaders as $header => $value) { + $response->headers->set($header, (string)$value); + } + } + } + } + +} diff --git a/backend/src/Service/App/RateLimit/RateLimiterProvider.php b/backend/src/Service/App/RateLimit/RateLimiterProvider.php new file mode 100644 index 00000000..a87110b8 --- /dev/null +++ b/backend/src/Service/App/RateLimit/RateLimiterProvider.php @@ -0,0 +1,38 @@ + $config + */ + public function factory(array $config): RateLimiterFactory + { + $storage = new CacheStorage($this->cacheItemPool); + return new RateLimiterFactory($config, $storage, $this->lockFactory); + } + + /** + * @param array $config + */ + public function rateLimiter(array $config, string $key): LimiterInterface + { + $rateLimiter = $this->factory($config); + return $rateLimiter->create($key); + } + +} diff --git a/backend/tests/Api/Console/RateLimitTest.php b/backend/tests/Api/Console/RateLimitTest.php new file mode 100644 index 00000000..0d00fc57 --- /dev/null +++ b/backend/tests/Api/Console/RateLimitTest.php @@ -0,0 +1,191 @@ + $newsletter, + ]); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'thibault@hyvor.com', + 'list_ids' => [$list->getId()], + ] + ); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('X-RateLimit-Limit', '100'); + $this->assertResponseHeaderSame('X-RateLimit-Remaining', '99'); + $this->assertResponseHeaderSame('X-RateLimit-Reset', '0'); + } + + public function test_429_on_rate_limited(): void + { + $newsletter = NewsletterFactory::createOne(['user_id' => 1]); + $list = NewsletterListFactory::createOne([ + 'newsletter' => $newsletter, + ]); + + $rateLimit = new RateLimit(); + /** @var RateLimiterProvider $rateLimiterProvider */ + $rateLimiterProvider = $this->getContainer()->get(RateLimiterProvider::class); + + $limiter = $rateLimiterProvider->rateLimiter($rateLimit->session(), "user:1"); + $limiter->consume(60); + $limiter->consume(10); + + $response = $this->consoleApi( + $newsletter, + 'POST', + '/subscribers', + [ + 'email' => 'thibault@hyvor.com', + 'list_ids' => [$list->getId()], + ], + useSession: true + ); + + $this->assertResponseStatusCodeSame(429); + + $this->assertResponseHeaderSame('X-RateLimit-Limit', '60'); + $this->assertResponseHeaderSame('X-RateLimit-Remaining', '0'); + $this->assertResponseHeaderSame('X-RateLimit-Reset', '60'); + } + + public function test_subscribe_endpoint_applies_per_minute_email_rate_limit(): void + { + $this->mockRelayClient(); + $newsletter = NewsletterFactory::createOne(); + SendingProfileFactory::createOne(['newsletter' => $newsletter]); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => 'test@example.com', + 'list_ids' => [$list->getId()] + ] + ); + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => 'test@example.com', + 'list_ids' => [$list->getId()] + ] + ); + + $this->assertResponseStatusCodeSame(200); + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => 'test@example.com', + 'list_ids' => [$list->getId()] + ] + ); + + $this->assertResponseStatusCodeSame(429); + $json = $this->getJson(); + $this->assertIsString($json['message']); + $this->assertStringContainsString('You have recently requested a subscription confirm link', $json['message']); + $this->assertStringContainsString('seconds', $json['message']); + } + + public function test_subscribe_endpoint_different_emails_not_rate_limited(): void + { + $this->mockRelayClient(); + $newsletter = NewsletterFactory::createOne(); + SendingProfileFactory::createOne(['newsletter' => $newsletter]); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => 'test1@example.com', + 'list_ids' => [$list->getId()] + ] + ); + + $this->assertResponseStatusCodeSame(200); + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => 'test2@example.com', + 'list_ids' => [$list->getId()] + ] + ); + + $this->assertResponseStatusCodeSame(200); + } + + public function test_subscribe_endpoint_hourly_rate_limit(): void + { + $this->mockRelayClient(); + $newsletter = NewsletterFactory::createOne(); + SendingProfileFactory::createOne(['newsletter' => $newsletter]); + $list = NewsletterListFactory::createOne(['newsletter' => $newsletter]); + + $rateLimit = new RateLimit(); + /** @var RateLimiterProvider $rateLimiterProvider */ + $rateLimiterProvider = $this->getContainer()->get(RateLimiterProvider::class); + + $email = 'test@example.com'; + + // Consume the hourly limit (6 requests) + $perHourLimiter = $rateLimiterProvider->rateLimiter( + $rateLimit->subscriberPerEmailPerHour(), + 'subscriber_email:' . $email + ); + $perHourLimiter->consume(6); + + + $response = $this->publicApi( + 'POST', + '/form/subscribe', + [ + 'newsletter_subdomain' => $newsletter->getSubdomain(), + 'email' => $email, + 'list_ids' => [$list->getId()] + ] + ); + + $this->assertResponseStatusCodeSame(429); + $json = $this->getJson(); + $this->assertIsString($json['message']); + $this->assertStringContainsString('You have recently requested a subscription confirm link', $json['message']); + $this->assertStringContainsString('seconds', $json['message']); + } +} diff --git a/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php b/backend/tests/Api/Machine/Integration/Relay/RelayWebhookTest.php similarity index 90% rename from backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php rename to backend/tests/Api/Machine/Integration/Relay/RelayWebhookTest.php index fb738da0..cf0984f9 100644 --- a/backend/tests/Api/Public/Integration/Relay/RelayWebhookTest.php +++ b/backend/tests/Api/Machine/Integration/Relay/RelayWebhookTest.php @@ -1,6 +1,6 @@ publicApi( + return $this->machineApi( 'POST', '/integration/relay/webhook', $data @@ -342,4 +342,36 @@ public function test_ignore_webhooks_for_emails_without_send_id(): void $response = $this->callWebhook($data); $this->assertSame(200, $response->getStatusCode()); } -} \ No newline at end of file + + public function test_machine_api_has_no_rate_limits(): void + { + $send = SendFactory::createOne([ + 'status' => SendStatus::PENDING, + 'delivered_at' => null + ]); + + $data = [ + "event" => "send.recipient.accepted", + "payload" => [ + "send" => [ + "headers" => [ + "X-Newsletter-Send-ID" => $send->getId() + ] + ], + "attempt" => [ + "created_at" => 1758221942 + ] + ] + ]; + + // Make 40 requests (more than the public API limit of 30) + for ($i = 0; $i < 40; $i++) { + $response = $this->callWebhook($data); + $this->assertSame(200, $response->getStatusCode()); + + $this->assertFalse($response->headers->has('RateLimit-Limit')); + $this->assertFalse($response->headers->has('RateLimit-Remaining')); + $this->assertFalse($response->headers->has('RateLimit-Reset')); + } + } +} diff --git a/backend/tests/Api/Public/RateLimitTest.php b/backend/tests/Api/Public/RateLimitTest.php new file mode 100644 index 00000000..2f4eb3b2 --- /dev/null +++ b/backend/tests/Api/Public/RateLimitTest.php @@ -0,0 +1,81 @@ +getSubdomain(); + + $response = $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('RateLimit-Limit', '30'); + $this->assertResponseHeaderSame('RateLimit-Remaining', '29'); + $this->assertTrue($this->client->getResponse()->headers->has('RateLimit-Reset')); + } + + public function test_429_on_rate_limited(): void + { + $newsletter = NewsletterFactory::createOne(); + $subdomain = $newsletter->getSubdomain(); + + // Make 30 requests to exhaust the rate limit + for ($i = 0; $i < 30; $i++) { + $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ]); + } + + // 31st request should be rate limited + $response = $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ]); + + $this->assertResponseStatusCodeSame(429); + $this->assertResponseHeaderSame('RateLimit-Limit', '30'); + $this->assertResponseHeaderSame('RateLimit-Remaining', '0'); + } + + public function test_rate_limit_per_ip(): void + { + $newsletter = NewsletterFactory::createOne(); + $subdomain = $newsletter->getSubdomain(); + + // Make 30 requests from first IP + for ($i = 0; $i < 30; $i++) { + $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ], clientIp: '192.168.1.1'); + } + + // 31st request from first IP should be rate limited + $response = $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ], clientIp: '192.168.1.1'); + + $this->assertResponseStatusCodeSame(429); + + // Request from different IP should succeed + $response = $this->publicApi('POST', '/form/init', [ + 'newsletter_subdomain' => $subdomain, + ], clientIp: '192.168.1.2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('RateLimit-Remaining', '29'); + } +} diff --git a/backend/tests/Case/WebTestCase.php b/backend/tests/Case/WebTestCase.php index f1ecf983..26f9faa9 100644 --- a/backend/tests/Case/WebTestCase.php +++ b/backend/tests/Case/WebTestCase.php @@ -170,6 +170,39 @@ public function consoleApi( * @param array $headers */ public function publicApi( + string $method, + string $uri, + array $data = [], + array $headers = [], + ?string $clientIp = null, + ): Response + { + $server = [ + 'CONTENT_TYPE' => 'application/json', + ]; + + if ($clientIp !== null) { + $server['REMOTE_ADDR'] = $clientIp; + } + + foreach ($headers as $key => $value) { + $server['HTTP_' . strtoupper(str_replace('-', '_', $key))] = $value; + } + + $this->client->request( + $method, + '/api/public' . $uri, + server: $server, + content: (string)json_encode($data) + ); + return $this->client->getResponse(); + } + + /** + * @param array $data + * @param array $headers + */ + public function machineApi( string $method, string $uri, array $data = [], @@ -186,7 +219,7 @@ public function publicApi( $this->client->request( $method, - '/api/public' . $uri, + '/api/machine' . $uri, server: $server, content: (string)json_encode($data) );