From 042decc60cf3777134aa1e3929988a3b1e1625a3 Mon Sep 17 00:00:00 2001 From: Kyrch Date: Fri, 10 Apr 2026 01:03:59 -0300 Subject: [PATCH] feat(graphql): apply rate limit per request (#1163) --- .env.example | 1 - .env.example-sail | 1 - app/Http/Kernel.php | 3 +- .../Middleware/GraphQL/RateLimitPerQuery.php | 89 ------------------- app/Providers/RouteServiceProvider.php | 21 ++++- config/graphql.php | 2 - .../GraphQL/RateLimitPerQueryTest.php | 86 ------------------ 7 files changed, 21 insertions(+), 182 deletions(-) delete mode 100644 app/Http/Middleware/GraphQL/RateLimitPerQuery.php delete mode 100644 tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php diff --git a/.env.example b/.env.example index 2c6c06b87..dd1275da0 100644 --- a/.env.example +++ b/.env.example @@ -175,7 +175,6 @@ FORTIFY_URL=http://localhost # graphql GRAPHQL_URL= GRAPHQL_PATH=/graphql -GRAPHQL_RATE_LIMIT=135 GRAPHIQL_ENABLED=true # hashids diff --git a/.env.example-sail b/.env.example-sail index 47656c2c0..17aa5415b 100644 --- a/.env.example-sail +++ b/.env.example-sail @@ -173,7 +173,6 @@ FORTIFY_URL=http://localhost # graphql GRAPHQL_URL= GRAPHQL_PATH=/graphql -GRAPHQL_RATE_LIMIT=135 GRAPHIQL_ENABLED=true # hashids diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index fb3168028..ec9e8e6f1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -8,7 +8,6 @@ use App\Http\Middleware\Api\SetAcceptJsonHeader; use App\Http\Middleware\Api\SetServingJsonApi; use App\Http\Middleware\Auth\Authenticate; use App\Http\Middleware\Auth\RedirectIfAuthenticated; -use App\Http\Middleware\GraphQL\RateLimitPerQuery; use App\Http\Middleware\GraphQL\RequiresContentType; use App\Http\Middleware\GraphQL\SetServingGraphQL; use App\Http\Middleware\LogRequest; @@ -107,7 +106,7 @@ class Kernel extends HttpKernel SubstituteBindings::class, // Rate limiting GraphQL to prevent abuse. - RateLimitPerQuery::class, + 'throttle:graphql', ], ]; diff --git a/app/Http/Middleware/GraphQL/RateLimitPerQuery.php b/app/Http/Middleware/GraphQL/RateLimitPerQuery.php deleted file mode 100644 index 18bd98e49..000000000 --- a/app/Http/Middleware/GraphQL/RateLimitPerQuery.php +++ /dev/null @@ -1,89 +0,0 @@ -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) { - return $next($request); - } - - $rootFields = 0; - - try { - $ast = Parser::parse($query); - - foreach ($ast->definitions as $definition) { - if ($definition instanceof OperationDefinitionNode) { - $rootFields += count($definition->selectionSet->selections ?? []); - } - } - } catch (JsonException|SyntaxError) { - // Do nothing - } - - $hits = max(1, $rootFields); - - $key = sprintf('graphql:%s', Auth::id() ?? $ip); - - $current = RateLimiter::attempts($key); - - if (($current + $hits) > $limit) { - $retryAfter = RateLimiter::availableIn($key); - - return new JsonResponse([ - 'message' => 'Too Many Attempts.', - ], 429, [ - 'Retry-After' => $retryAfter, - 'X-RateLimit-Limit' => $limit, - 'X-RateLimit-Remaining' => RateLimiter::remaining($key, $limit), - 'X-RateLimit-Reset' => now()->addSeconds($retryAfter)->getTimestampMs(), - ]); - } - - RateLimiter::increment($key, amount: $hits); - - return $next($request) - ->withHeaders([ - 'X-RateLimit-Limit' => $limit, - 'X-RateLimit-Remaining' => RateLimiter::remaining($key, $limit), - ]); - } -} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2f22f892b..cb464df85 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -46,7 +46,26 @@ class RouteServiceProvider extends ServiceProvider $ip = $forwardedIp; } - return Limit::perMinute(90)->by(Auth::check() ? Auth::id() : $ip); + return Limit::perMinute(90)->by(Auth::id() ?? $ip); + }); + + RateLimiter::for('graphql', function (Request $request) { + $user = $request->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 GraphQL 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(90)->by(Auth::id() ?? $ip); }); RateLimiter::for('video', function (Request $request) { diff --git a/config/graphql.php b/config/graphql.php index 21a79d8df..21e44b201 100644 --- a/config/graphql.php +++ b/config/graphql.php @@ -8,8 +8,6 @@ use App\GraphQL\Schema\Types\Base\PaginationInfoType; return [ 'domain' => env('GRAPHQL_URL', env('APP_URL')), - 'rate_limit' => (int) env('GRAPHQL_RATE_LIMIT', 135), - 'route' => [ // The prefix for routes; do NOT use a leading slash! 'prefix' => env('GRAPHQL_PATH', '/'), diff --git a/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php b/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php deleted file mode 100644 index b2ca659a2..000000000 --- a/tests/Feature/Http/Middleware/GraphQL/RateLimitPerQueryTest.php +++ /dev/null @@ -1,86 +0,0 @@ - '{ 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(); - - actingAs($user); - - $response = 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()) - ->postJson(route('graphql'), [ - 'query' => $query, - ]); - - $response->assertHeader('X-RateLimit-Limit'); - $response->assertHeader('X-RateLimit-Remaining'); - - $rateLimit = Config::integer('graphql.rate_limit'); - - expect((int) $response->headers->get('X-RateLimit-Remaining')) - ->toBe($rateLimit - $count); -}); - -test('user without bypass rate limited per query', function () { - $user = User::factory()->createOne(); - - 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(), - ])->postJson(route('graphql'), [ - 'query' => $query, - ]); - - $response->assertHeader('X-RateLimit-Limit'); - $response->assertHeader('X-RateLimit-Remaining'); - - $rateLimit = Config::integer('graphql.rate_limit'); - - expect((int) $response->headers->get('X-RateLimit-Remaining')) - ->toBe($rateLimit - $count); -});