After a few days of struggling I have found a few-lines long solution to the problem of how to show ReCaptcha after a few hits on the endpoint. It is useful for for instance for payment gateway integration, where this way you make sure attacker is not abusing your app to find out which card numbers are real and which not. Requiring a ReCaptcha after a few successive hits in a short amount of time greatly reduces this attack vector. Let's take a look, assuming Laravel 8:
The middleware class simply overrides the
handle
method
of the standard \Illuminate\Routing\Middleware\ThrottleRequests
throttling middleware:
<?php
namespace App\Middleware;
use App\Http\Middleware\MyReCaptchaMiddleware;
use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests;
class MyReCaptchaThrottleRequests extends ThrottleRequests
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param int|string $maxAttempts
* @param float|int $decayMinutes
* @param string $prefix
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
*/
public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
{
return $this->handleRequest(
$request,
$next,
[
(object) [
'key' => $prefix.$this->resolveRequestSignature($request),
'maxAttempts' => $this->resolveMaxAttempts($request, $maxAttempts),
'decayMinutes' => $decayMinutes,
'responseCallback' => fn() => app(MyReCaptchaMiddleware::class)->handle($request, $next),
],
]
);
}
The above assumes that you already have your own MyReCaptchaMiddleware
up
and running, we won't go into details of it here. There are many guides
already. It will be called via the responseCallback
, which in the parent
function is normally null
. Most magic happens basically at this very
line.
Next
add
the route middleware inside the app/Http/Kernel.php
like this:
protected $routeMiddleware = [
// ...
'throttle_recaptcha' => MyReCaptchaThrottleRequests::class,
];
The last step is to actually assign the throttle_recaptcha
middleware to
the route:
Route::get('/endpoint', function () {
//
})->middleware('throttle_recaptcha');
Note it is possible to add the maxAttempts
optional parameter:
Route::get('/endpoint', function () {
//
})->middleware('throttle_recaptcha:10');
And even a decayMinutes
, as a second parameter:
Route::get('/endpoint', function () {
//
})->middleware('throttle_recaptcha:10,2');
The above will require ReCaptcha verication on our endpoint after 10 hits
and will persist Requiring it for 2 straight minutes afterward. That's it.
Note that this will still rate-limit the actual endpoint for all it's
consumers, depending on your needs, it might need more tweaking to throttle
the endpoint per-user! This can be easily done by overriding
resolveRequestSignature()
method
as well.
Named limiters
There is a paragraph I purposefully removed from the top parent handle()
function, as you might see. For the record, it's
this one:
if (is_string($maxAttempts)
&& func_num_args() === 3
&& ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {
return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter);
}
The removed code serves for a useful feature, loosely called
named limiters
where in the place of $maxAttempts
parameter is the string with the name
of the throttle rate limiter:
Route::middleware(['throttle:my_rate_limiter'])->group(function () {
Route::post('/endpoint', function () {
//
});
});
The above would use the rate limiter definition like this one:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
protected function configureRateLimiting()
{
RateLimiter::for('my_rate_limiter', function (Request $request) {
return Limit::perMinute(1000);
});
}
But since we rather use the provided $maxAttempts
and $decayMinutes
middleware parameters of the throttle middleware only for their intended
purpose, we can safely omit that block. Also, for now hopefully obvious
reasons, it is not possible to use parameters with named limiters.
If you intend to use named limiters also with the throttle_recaptcha
middleware, you can keep that block in. It won't hurt at all. I found it's
code confusing as $maxAttempts
parameter is definitely not a good name
for the actual max attempts as well as the name of the rate limiter. I
suspect that the named limiters feature was probably added in as
afterthought. I did not want to confuse my colleagues during the code
review further, so I omitted it. But it is a useful feature nevertheless,
so keep that in mind. Enjoy!
Rate limiter headers
There are two headers that are added to the rate limited endpoints:
X-RateLimit-Limit
X-RateLimit-Remainig
It's the latter I am using to determine when to display the ReCaptcha on
the front-end. Simply, if the remaining hits shown in the
X-RateLimit-Remainig
response header equals to one, I know that all the
successive requests will require ReCaptcha token, so that is the exact time
to make user (or the automated bot) pass the test, obtaining the token and
attaching it to legitimate successive requests.
As a side note, now the X-RateLimit-Remainig
will always see two hits,
due to the issue discussed
here
which is unfortunate, but can be worked around.
Enjoy!
Links
- https://laracasts.com/discuss/channels/laravel/call-a-middleware-from-another-middleware?reply=288672
- https://laracasts.com/discuss/channels/laravel/how-to-customize-throttle-error?reply=724946
- https://stackoverflow.com/questions/63873681/laravel-customize-response-headers-when-using-rate-limiting-middleware
- https://www.codecheef.org/article/how-to-implement-rate-limiting-in-laravel-8
- https://bannister.me/blog/custom-throttle-middleware
- https://laracasts.com/discuss/channels/laravel/fortify-rate-throttling-redirecting-opposed-to-error-in-session?reply=696285
- https://www.cloudways.com/blog/laravel-and-api-rate-limiting/
- https://dev.to/aliadhillon/new-simple-way-of-creating-custom-rate-limiters-in-laravel-8-65n
- https://stackoverflow.com/questions/66102519/laravel-ratelimiter-throttle-increasing-decay-minutes?rq=1
- https://stackoverflow.com/questions/70820870/laravel-rate-limiter-limits-access-wrongly-after-only-one-attempt
- https://laraveldaily.com/laravel-too-many-login-attempts-restrict-and-customize/
- https://www.tutorialsbuddy.com/adding-google-recaptcha-in-laravel