fix(auth): rate limit by proxy-aware client ip instead of REMOTE_ADDR

This commit is contained in:
peaklabs-dev 2026-08-26 17:12:01 +02:00
parent 65a7520553
commit 3842bc2442
No known key found for this signature in database
4 changed files with 67 additions and 51 deletions

View file

@ -4,7 +4,6 @@
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
@ -22,8 +21,6 @@ class CreateNewUser implements CreatesNewUsers
private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600;
public function __construct(private readonly Request $request) {}
/**
* Validate and create a newly registered user.
*
@ -95,7 +92,7 @@ private function ensureRegistrationIsNotRateLimited(array $input): void
{
$keys = [
[
'key' => 'registration:ip:'.sha1($this->realIp()),
'key' => 'registration:ip:'.sha1((string) request()->ip()),
'max' => self::REGISTRATION_IP_MAX_ATTEMPTS,
'decay' => self::REGISTRATION_IP_DECAY_SECONDS,
],
@ -120,9 +117,4 @@ private function ensureRegistrationIsNotRateLimited(array $input): void
RateLimiter::hit($limit['key'], $limit['decay']);
}
}
private function realIp(): string
{
return $this->request->server('REMOTE_ADDR') ?? $this->request->ip();
}
}

View file

@ -9,10 +9,8 @@
use App\Models\OauthSetting;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\RegisterResponse;
use Laravel\Fortify\Fortify;
@ -122,45 +120,5 @@ public function boot(): void
Fortify::twoFactorChallengeView(function () {
return view('auth.two-factor-challenge');
});
RateLimiter::for('force-password-reset', function (Request $request) {
return Limit::perMinute(15)->by($request->user()->id);
});
RateLimiter::for('forgot-password', function (Request $request) {
// Use real client IP (not spoofable forwarded headers)
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
$limits = [
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)),
];
$emailIdentity = normalize_email_identity($request->input('email'));
if ($emailIdentity !== null) {
$limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity));
}
return $limits;
});
RateLimiter::for('login', function (Request $request) {
$email = (string) $request->email;
// Use email + real client IP (not spoofable forwarded headers)
// server('REMOTE_ADDR') gives the actual connecting IP before proxy headers
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
return Limit::perMinute(5)->by($email.'|'.$realIp);
});
RateLimiter::for('magic-link', function (Request $request) {
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
$token = (string) $request->input('token');
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
}
}

View file

@ -58,5 +58,34 @@ protected function configureRateLimiting(): void
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by((string) $request->email.'|'.$request->ip());
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
RateLimiter::for('forgot-password', function (Request $request) {
$limits = [
Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1((string) $request->ip())),
];
$emailIdentity = normalize_email_identity($request->input('email'));
if ($emailIdentity !== null) {
$limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity));
}
return $limits;
});
RateLimiter::for('magic-link', function (Request $request) {
return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.$request->ip()));
});
RateLimiter::for('force-password-reset', function (Request $request) {
return Limit::perMinute(15)->by($request->user()->id);
});
}
}

View file

@ -72,6 +72,43 @@
->assertTooManyRequests();
});
it('keeps clients behind the same reverse proxy in separate ip rate limit buckets', function () {
foreach (range(1, 3) as $attempt) {
$this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2'])
->withHeaders(['X-Forwarded-For' => '203.0.113.50'])
->post('/register', [
'name' => "Proxied User {$attempt}",
'email' => "proxied{$attempt}@example.com",
'password' => 'Password1!@',
'password_confirmation' => 'Password1!@',
])
->assertRedirect();
auth()->logout();
$this->flushSession();
}
$this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2'])
->withHeaders(['X-Forwarded-For' => '203.0.113.50'])
->post('/register', [
'name' => 'Blocked User',
'email' => 'blocked-proxied@example.com',
'password' => 'Password1!@',
'password_confirmation' => 'Password1!@',
])
->assertTooManyRequests();
$this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2'])
->withHeaders(['X-Forwarded-For' => '203.0.113.51'])
->post('/register', [
'name' => 'Other Client',
'email' => 'other-proxied@example.com',
'password' => 'Password1!@',
'password_confirmation' => 'Password1!@',
])
->assertRedirect();
});
it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () {
$registrationIpKey = 'registration:ip:'.sha1('127.0.0.1');
$emails = [