Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/Exceptions/Handler.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;
use Illuminate\Validation\ValidationException;
use Illuminate\Auth\Access\AuthorizationException;
Expand Down Expand Up @@ -68,6 +69,10 @@ public function render($request, Throwable $e)
}

if ($request->expectsJson() || $request->is('api/*')) {
if ($e instanceof TooManyRequestsHttpException) {
// keep Retry-After / X-RateLimit-* so clients can back off instead of seeing a fake server fault
return response()->json(['message' => 'Too Many Requests'], 429, $e->getHeaders());
}
$status = $this->isHttpException($e) ? $e->getStatusCode() : 500;
return response()->json(['message' => 'server error'], $status);
}
Expand Down
18 changes: 18 additions & 0 deletions app/Http/Middleware/RateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,22 @@ public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes
$this->calculateRemainingAttempts($key, $maxAttempts)
);
}

/**
* Scopes the counter per HTTP method + matched route template + client, so the budget (and the
* window, which the first writer of a key fixes) of one rate limited route never leaks into another.
* The route TEMPLATE is used, not the concrete URL, so path values do not create a bucket each.
* @param \Illuminate\Http\Request $request
* @return string
*/
protected function resolveRequestSignature($request)
{
return sha1(sprintf
(
'%s|%s|%s',
$request->getMethod(),
$request->route()?->uri() ?? $request->path(),
parent::resolveRequestSignature($request)
));
}
}
99 changes: 99 additions & 0 deletions tests/Unit/RateLimitMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php namespace Tests\Unit;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use App\Exceptions\Handler;
use App\Http\Middleware\RateLimitMiddleware;
use App\Models\ResourceServer\IApiEndpointRepository;
use App\Models\ResourceServer\IEndpointRateLimitByIPRepository;
use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\RateLimiter;
use Illuminate\Cache\Repository;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;

/**
* Class RateLimitMiddlewareTest
* Each rate limited route must own its counter and window per client IP.
*/
final class RateLimitMiddlewareTest extends TestCase
{
private const NOTIFY_URI = '/api/v1/summits/1/presentations/1/submission-period/reopen/notify';
private const APPLY_URI = '/api/v1/summits/1/promo-codes/ABC/apply';

private function buildMiddleware(): RateLimitMiddleware
{
return new RateLimitMiddleware(
$this->createMock(IApiEndpointRepository::class),
$this->createMock(IEndpointRateLimitByIPRepository::class),
new RateLimiter(new Repository(new ArrayStore()))
);
}

private function buildRequest(string $method, string $uri, string $ip): Request
{
$request = Request::create($uri, $method, [], [], [], ['REMOTE_ADDR' => $ip]);
$request->setRouteResolver(fn() => Route::getRoutes()->match($request));
return $request;
}

private function next(): \Closure
{
return fn($request) => new JsonResponse(['ok' => true], 200);
}

public function test_handle_counter_of_one_route_does_not_block_another_route_for_same_ip(): void
{
$middleware = $this->buildMiddleware();

// exhaust the notify budget (30 per 3600 seconds) from one IP
for ($i = 0; $i < 30; $i++) {
$middleware->handle($this->buildRequest('PUT', self::NOTIFY_URI, '10.0.0.1'), $this->next(), 30, 3600);
}

// a different route from the same IP has its own budget
$response = $middleware->handle($this->buildRequest('GET', self::APPLY_URI, '10.0.0.1'), $this->next(), 25, 1);

$this->assertSame(200, $response->getStatusCode());
}

public function test_handle_same_route_still_enforces_its_own_limit(): void
{
$middleware = $this->buildMiddleware();

for ($i = 0; $i < 25; $i++) {
$middleware->handle($this->buildRequest('GET', self::APPLY_URI, '10.0.0.1'), $this->next(), 25, 60);
}

$this->expectException(ThrottleRequestsException::class);
$middleware->handle($this->buildRequest('GET', self::APPLY_URI, '10.0.0.1'), $this->next(), 25, 60);
}

public function test_handle_429_response_is_not_masked_as_server_error(): void
{
config(['app.debug' => false]);

$exception = new ThrottleRequestsException('Too Many Attempts.', null, ['Retry-After' => 60]);
$response = (new Handler($this->app))->render(
Request::create(self::APPLY_URI, 'GET'),
$exception
);

$this->assertSame(429, $response->getStatusCode());
$this->assertSame('Too Many Requests', json_decode($response->getContent(), true)['message']);
$this->assertSame('60', $response->headers->get('Retry-After'));
}
}
Loading