Recently we discovered in our application at work that it's possible to fire an unlimited amount of requests to the backend. This does not mean that we're open for DDoS attacks because our firewall is already blocking such requests. It was possible for an authenticated user to make requests to a report generation backend method which then forwards the informations to an AWS Lambda function via SQS.
It's clear that such behaviour is not intended so we had a look what we can achieve with Symfony and discovered the Rate Limiter component (https://symfony.com/doc/current/rate_limiter.html).
The configuration is self explanatory as well as the three types of limiters.
For real world usage the documentation recommends using an event listener which then limits the number of requests regarding to the configuration. This is nice and sometimes very useful.
But in our case it wouldn't work because we have a lot of requests which are allowed. So we figured out how to limit only requests to certain routes by using the new support for Services in Route Conditions (https://symfony.com/blog/new-in-symfony-6-1-services-in-route-conditions).
First here's an example configuration for the rate limiter (/config/rate_limiter.yaml):
framework:
rate_limiter:
api:
policy: 'token_bucket'
limit: 3
rate: { interval: '1 minute', amount: 1}
With this configuration we're allowing a maximum of 3 requests per client. After every minute a new token is added to the bucket. You can check how this works in the Symfony documentation.
Next we created a service (/Services/ApiRateLimiterService.php) which can be called to check for the access:
namespace App\Service;
use Reporting\Exception\RateLimitExceedException;
use Symfony\Bundle\FrameworkBundle\Routing\Attribute\AsRoutingConditionService;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\RateLimiter\RateLimiterFactory;
#[AsRoutingConditionService(alias: 'rate_limiter_service')]
class ApiRateLimiterService
{
public function __construct(
private readonly RateLimiterFactory $apiLimiter,
) {
}
public function limitApi(Request $request): bool
{
$limiter = $this->apiLimiter->create($request->getClientIp());
$consume = $limiter->consume();
if (false === $consume()->isAccepted()) {
throw new TooManyRequestsHttpException(message: sprintf('You reached the maximum of %s HTTP requests to this API endpoint.', (string) $consume->getLimit()));
}
return true;
}
}
Thanks to some nice features of Symfony regarding to PHP attributes we're able to configure this service as route condition service without touching the services.yaml.
Also keep attention to the constructor of the service which injects the RateLimiterFactory $apiLimiter. The format is _nameOfLimiter_Limiter taken from the rate_limiter.yaml.
So it's also possible to have multiple rate limiters with different configuration.
Say we want to have an additional limiter for a login:
framework:
rate_limiter:
api:
policy: 'token_bucket'
limit: 3
rate: { interval: '1 minute', amount: 1}
login:
policy: 'fixed_window'
limit: 3
interval: '15 minutes'
Then we would inject $apiLimiter and $loginLimiter into the service constructor. Thanks to the autowiring feature the desired limiters will be available in the service.
At last we have to tell Symfony where we want to limit the API. We're doing this in the controller in the route defintion:
#[Route(path: '/myapicall', name: 'callme', condition: "service('rate_limiter_service').limitApi(request)")]
public function myApiCallToLimit(Request $request): JsonResponse
{
}
When requesting more than 3 calls to '/myapicall' we'll have to wait at least a minute to make another call and then have to wait again.
In the token bucket policy every 'x' time units (can be seconds, minutes, hours, you name it) a specified amount of tokens will be added to the bucket until it's filled to it's maxium again.
This is my first "real" dev article and I hope it's useful for someone else as well.
Please don't hesitate to leave a comment if you want.
2022-11-11
Further notes: We're created an API backend with the FOSRestBundle and communicate between it and the frontend only via JSON. To catch the TooManyRequestsException we added our own ErrorController which then returns a JsonResponse.
Follow the Symfony documentation how to setup and implement custom error controllers.
Add a comment