diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ea249ad4f3..0fcb11df22 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -92,7 +92,7 @@ class CreateNewUser implements CreatesNewUsers { $keys = [ [ - 'key' => 'registration:ip:'.sha1((string) request()->ip()), + 'key' => 'registration:ip:'.sha1(auth_rate_limit_ip(request())), 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, ], diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index e07d39a084..79139c6b93 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -60,7 +60,7 @@ class RouteServiceProvider extends ServiceProvider }); RateLimiter::for('login', function (Request $request) { - return Limit::perMinute(5)->by((string) $request->email.'|'.$request->ip()); + return Limit::perMinute(5)->by((string) $request->email.'|'.auth_rate_limit_ip($request)); }); RateLimiter::for('two-factor', function (Request $request) { @@ -69,7 +69,7 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('forgot-password', function (Request $request) { $limits = [ - Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1((string) $request->ip())), + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), ]; $emailIdentity = normalize_email_identity($request->input('email')); @@ -81,7 +81,7 @@ class RouteServiceProvider extends ServiceProvider }); RateLimiter::for('magic-link', function (Request $request) { - return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.$request->ip())); + return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.auth_rate_limit_ip($request))); }); RateLimiter::for('force-password-reset', function (Request $request) { diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php new file mode 100644 index 0000000000..7580fc1e06 --- /dev/null +++ b/bootstrap/helpers/auth.php @@ -0,0 +1,14 @@ +header('CF-Connecting-IP'); + + if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) { + return $cloudflareIp; + } + + return (string) $request->ip(); +} diff --git a/tests/Feature/Auth/LoginRateLimitIPTest.php b/tests/Feature/Auth/LoginRateLimitIPTest.php index 04e6925f4d..9c2214c58a 100644 --- a/tests/Feature/Auth/LoginRateLimitIPTest.php +++ b/tests/Feature/Auth/LoginRateLimitIPTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); RateLimiter::clear('login'); @@ -66,3 +68,27 @@ test('successful login is still possible within rate limit', function () { $response->assertRedirect(); expect($response->status())->not->toBe(429); }); + +test('cloud login rate limits use the Cloudflare client ip', function () { + config()->set('constants.coolify.self_hosted', false); + + foreach (range(1, 5) as $attempt) { + $this->withHeader('CF-Connecting-IP', '2001:db8::10') + ->withHeader('X-Forwarded-For', '2001:db8::10, 108.162.221.29') + ->withServerVariables(['REMOTE_ADDR' => '10.0.0.5']) + ->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]) + ->assertRedirect(); + } + + $this->withHeader('CF-Connecting-IP', '2001:db8::20') + ->withHeader('X-Forwarded-For', '2001:db8::20, 108.162.221.29') + ->withServerVariables(['REMOTE_ADDR' => '10.0.0.5']) + ->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]) + ->assertRedirect(); +}); diff --git a/tests/Feature/AuthRateLimitClientIpTest.php b/tests/Feature/AuthRateLimitClientIpTest.php new file mode 100644 index 0000000000..ff32dab6a9 --- /dev/null +++ b/tests/Feature/AuthRateLimitClientIpTest.php @@ -0,0 +1,36 @@ +set('constants.coolify.self_hosted', false); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '10.0.0.5', + 'HTTP_CF_CONNECTING_IP' => '2001:db8::10', + ]); + + expect(auth_rate_limit_ip($request))->toBe('2001:db8::10'); +}); + +it('falls back to the resolved request ip when the Cloudflare header is invalid', function () { + config()->set('constants.coolify.self_hosted', false); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_CF_CONNECTING_IP' => 'invalid', + ]); + + expect(auth_rate_limit_ip($request))->toBe('203.0.113.10'); +}); + +it('ignores the Cloudflare header on self-hosted instances', function () { + config()->set('constants.coolify.self_hosted', true); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '203.0.113.20', + 'HTTP_CF_CONNECTING_IP' => '2001:db8::20', + ]); + + expect(auth_rate_limit_ip($request))->toBe('203.0.113.20'); +}); diff --git a/tests/Feature/ForgotPasswordRateLimitTest.php b/tests/Feature/ForgotPasswordRateLimitTest.php index 02dbce6507..20fbd22596 100644 --- a/tests/Feature/ForgotPasswordRateLimitTest.php +++ b/tests/Feature/ForgotPasswordRateLimitTest.php @@ -6,6 +6,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::query()->forceCreate(['id' => 0]); }); diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 5007c41d8d..4107e4a81d 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::query()->forceCreate([ 'id' => 0, 'is_registration_enabled' => true,