Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[11.x] Throw exception if named rate limiter and model property do not exist #50908

31 changes: 31 additions & 0 deletions src/Illuminate/Routing/Exceptions/MissingRateLimiterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Illuminate\Routing\Exceptions;

use Exception;

class MissingRateLimiterException extends Exception
{
/**
* Create a new exception for invalid named rate limiter.
*
* @param string $limiter
* @return static
*/
public static function forLimiter(string $limiter)
{
return new static("Rate limiter [{$limiter}] is not defined.");
}

/**
* Create a new exception for an invalid rate limiter based on a model property.
*
* @param string $limiter
* @param class-string $model
* @return static
*/
public static function forLimiterAndUser(string $limiter, string $model)
{
return new static("Rate limiter [{$model}::{$limiter}] is not defined.");
}
}
20 changes: 16 additions & 4 deletions src/Illuminate/Routing/Middleware/ThrottleRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Illuminate\Cache\RateLimiting\Unlimited;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Routing\Exceptions\MissingRateLimiterException;
use Illuminate\Support\Arr;
use Illuminate\Support\InteractsWithTime;
use RuntimeException;
Expand Down Expand Up @@ -78,6 +79,7 @@ public static function with($maxAttempts = 60, $decayMinutes = 1, $prefix = '')
* @return \Symfony\Component\HttpFoundation\Response
*
* @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
* @throws \Illuminate\Routing\Exceptions\MissingRateLimiterException
*/
public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
{
Expand Down Expand Up @@ -175,17 +177,27 @@ protected function handleRequest($request, Closure $next, array $limits)
* @param \Illuminate\Http\Request $request
* @param int|string $maxAttempts
* @return int
* @throws \Illuminate\Routing\Exceptions\MissingRateLimiterException
*/
protected function resolveMaxAttempts($request, $maxAttempts)
{
if (str_contains($maxAttempts, '|')) {
$maxAttempts = explode('|', $maxAttempts, 2)[$request->user() ? 1 : 0];
}

if (! is_numeric($maxAttempts) && $request->user()) {
if (! is_numeric($maxAttempts) &&
$request->user()?->hasAttribute($maxAttempts)
) {
$maxAttempts = $request->user()->{$maxAttempts};
}

// If we still don't have a numeric value, there was no matching rate limiter...
if (! is_numeric($maxAttempts)) {
is_null($request->user())
? throw MissingRateLimiterException::forLimiter($maxAttempts)
: throw MissingRateLimiterException::forLimiterAndUser($maxAttempts, get_class($request->user()));
}

return (int) $maxAttempts;
}

Expand Down Expand Up @@ -271,9 +283,9 @@ protected function addHeaders(Response $response, $maxAttempts, $remainingAttemp
* @return array
*/
protected function getHeaders($maxAttempts,
$remainingAttempts,
$retryAfter = null,
?Response $response = null)
$remainingAttempts,
$retryAfter = null,
?Response $response = null)
{
if ($response &&
! is_null($response->headers->get('X-RateLimit-Remaining')) &&
Expand Down
106 changes: 106 additions & 0 deletions tests/Integration/Http/ThrottleRequestsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,26 @@
use Illuminate\Cache\RateLimiting\GlobalLimit;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Routing\Exceptions\MissingRateLimiterException;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Route;
use Orchestra\Testbench\Attributes\WithConfig;
use Orchestra\Testbench\Attributes\WithMigration;
use Orchestra\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use Throwable;

#[WithConfig('hashing.driver', 'bcrypt')]
#[WithMigration]
class ThrottleRequestsTest extends TestCase
{
use RefreshDatabase;

public function testLockOpensImmediatelyAfterDecay()
{
Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 0));
Expand Down Expand Up @@ -233,4 +241,102 @@ public function testItCanThrottlePerSecond()
$response = $this->get('/');
$response->assertOk();
}

public function testItFailsIfNamedLimiterDoesNotExist()
{
$this->expectException(MissingRateLimiterException::class);
$this->expectExceptionMessage('Rate limiter [test] is not defined.');

Route::get('/', fn () => 'ok')->middleware(ThrottleRequests::using('test'));

$this->withoutExceptionHandling()->get('/');
}

public function testItFailsIfNamedLimiterDoesNotExistAndAuthenticatedUserDoesNotHaveFallbackProperty()
{
$this->expectException(MissingRateLimiterException::class);
$this->expectExceptionMessage('Rate limiter [' . User::class . '::rateLimiting] is not defined.');

Route::get('/', fn () => 'ok')->middleware(['auth', ThrottleRequests::using('rateLimiting')]);

// The reason we're enabling strict mode and actually creating a user is to ensure we never even try to access
// a property within the user model that does not exist. If an application is in strict mode and there is
// no matching rate limiter, it should throw a rate limiter exception, not a property access exception.
Model::shouldBeStrict();
$user = User::forceCreate([
'name' => 'Mateus',
'email' => '[email protected]',
'password' => 'password',
]);

$this->withoutExceptionHandling()->actingAs($user)->get('/');
}

public function testItFallbacksToUserPropertyWhenThereIsNoNamedLimiterWhenAuthenticated()
{
$user = User::make()->forceFill([
'rateLimiting' => 1,
]);

Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 0));

// The `rateLimiting` named limiter does not exist, but the `rateLimiting` property on the
// User model does, so it should fallback to that property within the authenticated model.
Route::get('/', fn () => 'yes')->middleware(['auth', ThrottleRequests::using('rateLimiting')]);

$response = $this->withoutExceptionHandling()->actingAs($user)->get('/');
$this->assertSame('yes', $response->getContent());
$this->assertEquals(1, $response->headers->get('X-RateLimit-Limit'));
$this->assertEquals(0, $response->headers->get('X-RateLimit-Remaining'));

Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 58));

try {
$this->withoutExceptionHandling()->actingAs($user)->get('/');
} catch (Throwable $e) {
$this->assertInstanceOf(ThrottleRequestsException::class, $e);
$this->assertEquals(429, $e->getStatusCode());
$this->assertEquals(1, $e->getHeaders()['X-RateLimit-Limit']);
$this->assertEquals(0, $e->getHeaders()['X-RateLimit-Remaining']);
$this->assertEquals(2, $e->getHeaders()['Retry-After']);
$this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']);
}
}

public function testItFallbacksToUserAccessorWhenThereIsNoNamedLimiterWhenAuthenticated()
{
$user = UserWithAcessor::make();

Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 0));

// The `rateLimiting` named limiter does not exist, but the `rateLimiting` accessor (not property!)
// on the User model does, so it should fallback to that accessor within the authenticated model.
Route::get('/', fn () => 'yes')->middleware(['auth', ThrottleRequests::using('rateLimiting')]);

$response = $this->withoutExceptionHandling()->actingAs($user)->get('/');
$this->assertSame('yes', $response->getContent());
$this->assertEquals(1, $response->headers->get('X-RateLimit-Limit'));
$this->assertEquals(0, $response->headers->get('X-RateLimit-Remaining'));

Carbon::setTestNow(Carbon::create(2018, 1, 1, 0, 0, 58));

try {
$this->withoutExceptionHandling()->actingAs($user)->get('/');
} catch (Throwable $e) {
$this->assertInstanceOf(ThrottleRequestsException::class, $e);
$this->assertEquals(429, $e->getStatusCode());
$this->assertEquals(1, $e->getHeaders()['X-RateLimit-Limit']);
$this->assertEquals(0, $e->getHeaders()['X-RateLimit-Remaining']);
$this->assertEquals(2, $e->getHeaders()['Retry-After']);
$this->assertEquals(Carbon::now()->addSeconds(2)->getTimestamp(), $e->getHeaders()['X-RateLimit-Reset']);
}
}
}

class UserWithAcessor extends User
{
public function getRateLimitingAttribute(): int
{
return 1;
}
}