From 269cf229963ba51b34b82fa7dbc6a33951a8fe8d Mon Sep 17 00:00:00 2001 From: Kyrch Date: Thu, 2 Oct 2025 15:25:29 -0300 Subject: [PATCH] feat(graphql): rate limit per query (#971) --- app/GraphQL/Schema/Schemas/DefaultSchema.php | 7 +- .../Api/Field/Aggregate/AggregateField.php | 2 +- .../Middleware/GraphQL/RateLimitPerQuery.php | 73 +++++++++++++++++ app/Providers/RouteServiceProvider.php | 19 ----- tests/Feature/Http/Api/ThrottleTest.php | 29 +++++-- .../GraphQL/RateLimitPerQueryTest.php | 82 +++++++++++++++++++ 6 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 app/Http/Middleware/GraphQL/RateLimitPerQuery.php create mode 100644 tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php diff --git a/app/GraphQL/Schema/Schemas/DefaultSchema.php b/app/GraphQL/Schema/Schemas/DefaultSchema.php index fbf00b0fa..06ccfadd7 100644 --- a/app/GraphQL/Schema/Schemas/DefaultSchema.php +++ b/app/GraphQL/Schema/Schemas/DefaultSchema.php @@ -88,6 +88,7 @@ use App\GraphQL\Schema\Unions\PerformanceArtistUnion; use App\GraphQL\Schema\Unions\ResourceableUnion; use App\Http\Middleware\GraphQL\LogGraphQLRequest; use App\Http\Middleware\GraphQL\MaxCount; +use App\Http\Middleware\GraphQL\RateLimitPerQuery; use App\Http\Middleware\GraphQL\SetServingGraphQL; use Rebing\GraphQL\Support\Contracts\ConfigConvertible; @@ -219,12 +220,12 @@ class DefaultSchema implements ConfigConvertible ], // Laravel HTTP middleware 'middleware' => [ + // Rate limiting GraphQL to prevent abuse. + RateLimitPerQuery::class, + // Set the serving context to graphql. SetServingGraphQL::class, - // Rate limiting GraphQL to prevent abuse. - 'throttle:graphql', - // Allow client to get full database. MaxCount::class, diff --git a/app/Http/Api/Field/Aggregate/AggregateField.php b/app/Http/Api/Field/Aggregate/AggregateField.php index 12964ea86..5abe2d53b 100644 --- a/app/Http/Api/Field/Aggregate/AggregateField.php +++ b/app/Http/Api/Field/Aggregate/AggregateField.php @@ -74,7 +74,7 @@ abstract class AggregateField extends Field implements FilterableField, Renderab // Select aggregate if sorting on the aggregate value $sort = $this->getSort(); - return array_any($query->getSortCriteria(), fn ($sortCriterion) => $sortCriterion->shouldSort($sort, $scope)); + return array_any($query->getSortCriteria(), fn ($sortCriterion): bool => $sortCriterion->shouldSort($sort, $scope)); } /** diff --git a/app/Http/Middleware/GraphQL/RateLimitPerQuery.php b/app/Http/Middleware/GraphQL/RateLimitPerQuery.php new file mode 100644 index 000000000..fd7562cc7 --- /dev/null +++ b/app/Http/Middleware/GraphQL/RateLimitPerQuery.php @@ -0,0 +1,73 @@ +input('query'); + $ip = $request->ip(); + $forwardedIp = $request->header('x-forwarded-ip'); + + // (If request is from client and no forwarded ip) or (the user logged in has permission to bypass API rate limiting) + /** @phpstan-ignore-next-line */ + if (($ip === '127.0.0.1' && ! $forwardedIp) || ($user?->can(SpecialPermission::BYPASS_GRAPHQL_RATE_LIMITER->value))) { + return $next($request); + } + + // Check if request is from client to prevent users from using forwarded ip + if ($ip === '127.0.0.1' && $forwardedIp) { + $ip = $forwardedIp; + } + + if ($query) { + try { + $ast = Parser::parse($query); + } catch (JsonException) { + return $next($request); + } + + $rootFields = 0; + + foreach ($ast->definitions as $definition) { + if ($definition instanceof OperationDefinitionNode) { + $rootFields += count($definition->selectionSet->selections ?? []); + } + } + + $hits = max(1, $rootFields); + + $key = sprintf('graphql:%s', Auth::id() ?? $ip); + + foreach (range(1, $hits) as $_) { + if (RateLimiter::tooManyAttempts($key, 80)) { + return response()->json([ + 'message' => 'Too many requests', + ], 429); + } + + RateLimiter::hit($key); + } + + return $next($request) + ->withHeaders([ + 'X-RateLimit-Limit' => 80, + 'X-RateLimit-Remaining' => RateLimiter::remaining($key, 80), + ]); + } + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 6974e36e1..0f83f9e1e 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -30,25 +30,6 @@ class RouteServiceProvider extends ServiceProvider */ public function boot(): void { - RateLimiter::for('graphql', function (Request $request) { - $user = Auth::user(); - $ip = $request->ip(); - $forwardedIp = $request->header('x-forwarded-ip'); - - // (If request is from client and no forwarded ip) or (the user logged in has permission to bypass API rate limiting) - /** @phpstan-ignore-next-line */ - if (($ip === '127.0.0.1' && ! $forwardedIp) || ($user instanceof User && $user->can(SpecialPermission::BYPASS_GRAPHQL_RATE_LIMITER->value))) { - return Limit::none(); - } - - // Check if request is from client to prevent users from using forwarded ip - if ($ip === '127.0.0.1' && $forwardedIp) { - $ip = $forwardedIp; - } - - return Limit::perMinute(80)->by(Auth::check() ? Auth::id() : $ip); - }); - RateLimiter::for('api', function (Request $request) { $user = $request->user('sanctum'); $ip = $request->ip(); diff --git a/tests/Feature/Http/Api/ThrottleTest.php b/tests/Feature/Http/Api/ThrottleTest.php index ef0ed2b5d..14632bd36 100644 --- a/tests/Feature/Http/Api/ThrottleTest.php +++ b/tests/Feature/Http/Api/ThrottleTest.php @@ -8,13 +8,6 @@ use Laravel\Sanctum\Sanctum; use function Pest\Laravel\get; -test('forwarded ip rate limited', function () { - $response = $this->withHeader('x-forwarded-ip', fake()->ipv4())->get(route('api.anime.index')); - - $response->assertHeader('X-RateLimit-Limit'); - $response->assertHeader('X-RateLimit-Remaining'); -}); - test('client no forwarded ip not rate limited', function () { $response = get(route('api.anime.index')); @@ -22,7 +15,7 @@ test('client no forwarded ip not rate limited', function () { $response->assertHeaderMissing('X-RateLimit-Remaining'); }); -test('user not rate limited', function () { +test('user with bypass not rate limited', function () { $user = User::factory()->withPermissions(SpecialPermission::BYPASS_API_RATE_LIMITER->value)->createOne(); Sanctum::actingAs($user); @@ -32,3 +25,23 @@ test('user not rate limited', function () { $response->assertHeaderMissing('X-RateLimit-Limit'); $response->assertHeaderMissing('X-RateLimit-Remaining'); }); + +test('forwarded ip rate limited', function () { + $response = $this->withHeader('x-forwarded-ip', fake()->ipv4())->get(route('api.anime.index')); + + $response->assertHeader('X-RateLimit-Limit'); + $response->assertHeader('X-RateLimit-Remaining'); +}); + +test('user without bypass rate limited', function () { + $user = User::factory()->createOne(); + + Sanctum::actingAs($user); + + $response = $this->withServerVariables([ + 'REMOTE_ADDR' => fake()->ipv4(), + ])->get(route('api.anime.index')); + + $response->assertHeader('X-RateLimit-Limit'); + $response->assertHeader('X-RateLimit-Remaining'); +})->only(); diff --git a/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php b/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php new file mode 100644 index 000000000..39640a327 --- /dev/null +++ b/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php @@ -0,0 +1,82 @@ + '{ animePagination(first: 1) { data { id } } }', + ]); + + $response->assertHeaderMissing('X-RateLimit-Limit'); + $response->assertHeaderMissing('X-RateLimit-Remaining'); +}); + +test('user with bypass not rate limited', function () { + $user = User::factory()->withPermissions(SpecialPermission::BYPASS_GRAPHQL_RATE_LIMITER->value)->createOne(); + + Sanctum::actingAs($user); + + $response = post(route('graphql'), [ + 'query' => '{ animePagination(first: 1) { data { id } } }', + ]); + + $response->assertHeaderMissing('X-RateLimit-Limit'); + $response->assertHeaderMissing('X-RateLimit-Remaining'); +}); + +test('forwarded ip rate limited per query', function () { + $count = fake()->numberBetween(1, 10); + + $query = '{'; + + foreach (range(1, $count) as $_) { + $query .= 'animePagination(first: 1) { data { id } }'; + } + + $query .= '}'; + + $response = $this->withHeader('x-forwarded-ip', fake()->ipv4()) + ->post(route('graphql'), [ + 'query' => $query, + ]); + + $response->assertHeader('X-RateLimit-Limit'); + $response->assertHeader('X-RateLimit-Remaining'); + + expect((int) $response->headers->get('X-RateLimit-Remaining')) + ->toBe(80 - $count); +}); + +test('user without bypass rate limited per query', function () { + $user = User::factory()->createOne(); + + Sanctum::actingAs($user); + + $count = fake()->numberBetween(1, 10); + + $query = '{'; + + foreach (range(1, $count) as $_) { + $query .= 'animePagination(first: 1) { data { id } }'; + } + + $query .= '}'; + + $response = $this->withServerVariables([ + 'REMOTE_ADDR' => fake()->ipv4(), + ])->post(route('graphql'), [ + 'query' => $query, + ]); + + $response->assertHeader('X-RateLimit-Limit'); + $response->assertHeader('X-RateLimit-Remaining'); + + expect((int) $response->headers->get('X-RateLimit-Remaining')) + ->toBe(80 - $count); +});