fix(security): sanitize rate limiter client IP resolution to prevent header spoofing (F-2026-0001)

This commit is contained in:
Jérémy Munsch 2026-08-26 09:41:40 +02:00
parent daa8d1e0fa
commit dd5bcdd351
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
2 changed files with 45 additions and 8 deletions

View file

@ -25,10 +25,6 @@ class AppServiceProvider extends ServiceProvider
#[\Override]
public function register(): void
{
$this->app->singleton('clientIp', function () {
return request()->server('HTTP_X_FORWARDED_FOR') ?? request()->getClientIp();
});
$this->app->bind(
HealthChecker::class,
LaravelSystemHealthChecker::class
@ -81,12 +77,12 @@ class AppServiceProvider extends ServiceProvider
protected function configureRateLimiting(ThrottlingConfig $throttlingConfig): void
{
RateLimiter::for('web', function (IlluminateRequest $_) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->webLimit->toInt())->by(resolve('clientIp'));
RateLimiter::for('web', function (IlluminateRequest $request) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->webLimit->toInt())->by($request->ip() ?? '127.0.0.1');
});
RateLimiter::for('api.v1', function (IlluminateRequest $_) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->apiV1Limit->toInt())->by(resolve('clientIp'));
RateLimiter::for('api.v1', function (IlluminateRequest $request) use ($throttlingConfig) {
return Limit::perMinute($throttlingConfig->apiV1Limit->toInt())->by($request->ip() ?? '127.0.0.1');
});
}
}

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1;
use Illuminate\Support\Facades\RateLimiter;
use Infra\Providers\AppServiceProvider;
covers(AppServiceProvider::class);
describe('API V1 Rate Limiting Feature Tests', function (): void {
it('applies rate limiting partitioned by client IP', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
RateLimiter::clear('api.v1:192.168.1.100');
// WHEN: Making requests within limit
$response = $this->withServerVariables(['REMOTE_ADDR' => '192.168.1.100'])
->getJson('/api/v1/health');
// THEN
$response->assertStatus(200);
expect($response->headers->has('X-RateLimit-Limit'))->toBeTrue();
expect($response->headers->has('X-RateLimit-Remaining'))->toBeTrue();
});
it('does not resolve rate limit key from untrusted raw X-Forwarded-For header', function (): void {
// GIVEN
/** @var \Tests\TestCase $this */
$clientIp = '10.20.30.40';
// WHEN: Making request with spoofed X-Forwarded-For header
$response = $this->withServerVariables(['REMOTE_ADDR' => $clientIp])
->withHeaders(['X-Forwarded-For' => '203.0.113.195'])
->getJson('/api/v1/health');
// THEN
$response->assertStatus(200);
});
});