Skip to content

Commit 94c36bc

Browse files
ryanmitchellclaudejasonvarga
authored
[6.x] Improve rate limiting (#14475)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 9ad48dd commit 94c36bc

5 files changed

Lines changed: 244 additions & 9 deletions

File tree

routes/cp.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,12 @@
122122
Route::group(['prefix' => 'auth'], function () {
123123
if (config('statamic.cp.auth.enabled', true)) {
124124
Route::get('login', [LoginController::class, 'showLoginForm'])->name('login');
125-
Route::post('login', [LoginController::class, 'login']);
125+
Route::post('login', [LoginController::class, 'login'])->middleware('throttle:statamic.cp.auth');
126126

127127
Route::get('password/reset', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request');
128-
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
128+
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:statamic.cp.auth')->name('password.email');
129129
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
130-
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action');
130+
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('throttle:statamic.cp.auth')->name('password.reset.action');
131131

132132
if (TwoFactor::enabled()) {
133133
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge');
@@ -148,7 +148,7 @@
148148

149149
Route::get('stop-impersonating', [ImpersonationController::class, 'stop'])->name('impersonation.stop');
150150

151-
Route::group(['prefix' => 'passkeys'], function () {
151+
Route::group(['prefix' => 'passkeys', 'middleware' => 'throttle:statamic.cp.passkeys'], function () {
152152
Route::post('/', [PasskeyLoginController::class, 'login'])->name('passkeys.auth');
153153
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.auth.options');
154154
});

routes/web.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,27 +34,27 @@
3434

3535
Route::name('statamic.')->group(function () {
3636
Route::group(['prefix' => config('statamic.routes.action')], function () {
37-
Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandlePrecognitiveRequests::class])->name('forms.submit');
37+
Route::post('forms/{form}', [FormController::class, 'submit'])->middleware([HandlePrecognitiveRequests::class, 'throttle:statamic.forms'])->name('forms.submit');
3838

3939
Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]);
4040
Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store');
4141

4242
Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () {
4343
Route::get('logout', [LoginController::class, 'logout'])->name('logout');
4444

45-
Route::group(['middleware' => [HandlePrecognitiveRequests::class]], function () {
45+
Route::group(['middleware' => [HandlePrecognitiveRequests::class, 'throttle:statamic.auth']], function () {
4646
Route::post('login', [LoginController::class, 'login'])->name('login');
4747
Route::post('register', RegisterController::class)->name('register');
4848
Route::post('profile', ProfileController::class)->name('profile');
4949
Route::post('password', PasswordController::class)->name('password');
5050
});
5151

52-
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
52+
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:statamic.auth')->name('password.email');
5353
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
54-
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action');
54+
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('throttle:statamic.auth')->name('password.reset.action');
5555

5656
Route::group(['prefix' => 'passkeys'], function () {
57-
Route::middleware(ThrottleRequests::class.':30,1')->group(function () {
57+
Route::middleware('throttle:statamic.passkeys')->group(function () {
5858
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.options');
5959
Route::post('auth', [PasskeyLoginController::class, 'login'])->name('passkeys.login');
6060
});

src/Providers/AuthServiceProvider.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ public function boot()
171171
: $broker;
172172
});
173173

174+
RateLimiter::for('statamic.auth', function (Request $request) {
175+
return Limit::perMinute(4)->by($request->ip());
176+
});
177+
178+
RateLimiter::for('statamic.cp.auth', function (Request $request) {
179+
return RateLimiter::limiter('statamic.auth')($request);
180+
});
181+
182+
RateLimiter::for('statamic.passkeys', function (Request $request) {
183+
return Limit::perMinute(30)->by($request->ip());
184+
});
185+
186+
RateLimiter::for('statamic.cp.passkeys', function (Request $request) {
187+
return RateLimiter::limiter('statamic.passkeys')($request);
188+
});
189+
190+
RateLimiter::for('statamic.forms', function (Request $request) {
191+
return Limit::perMinute(10)->by($request->ip());
192+
});
193+
174194
RateLimiter::for('two-factor', function (Request $request) {
175195
return Limit::perMinute(5)->by($request->session()->get('login.id'));
176196
});

tests/Feature/RateLimitingTest.php

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use Illuminate\Cache\RateLimiting\Limit;
6+
use Illuminate\Support\Facades\Cache;
7+
use Illuminate\Support\Facades\RateLimiter;
8+
use PHPUnit\Framework\Attributes\Test;
9+
use Tests\PreventSavingStacheItemsToDisk;
10+
use Tests\TestCase;
11+
12+
class RateLimitingTest extends TestCase
13+
{
14+
use PreventSavingStacheItemsToDisk;
15+
16+
protected function setUp(): void
17+
{
18+
parent::setUp();
19+
Cache::flush();
20+
}
21+
22+
#[Test]
23+
public function login_endpoint_is_rate_limited()
24+
{
25+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
26+
$this->post('/!/auth/login')->assertRateLimited();
27+
}
28+
29+
#[Test]
30+
public function register_endpoint_is_rate_limited()
31+
{
32+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/register')->assertNotRateLimited());
33+
$this->post('/!/auth/register')->assertRateLimited();
34+
}
35+
36+
#[Test]
37+
public function password_email_endpoint_is_rate_limited()
38+
{
39+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/password/email')->assertNotRateLimited());
40+
$this->post('/!/auth/password/email')->assertRateLimited();
41+
}
42+
43+
#[Test]
44+
public function password_reset_endpoint_is_rate_limited()
45+
{
46+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/password/reset')->assertNotRateLimited());
47+
$this->post('/!/auth/password/reset')->assertRateLimited();
48+
}
49+
50+
#[Test]
51+
public function forms_endpoint_is_rate_limited()
52+
{
53+
collect(range(1, 10))->each(fn () => $this->post('/!/forms/contact')->assertNotRateLimited());
54+
$this->post('/!/forms/contact')->assertRateLimited();
55+
}
56+
57+
#[Test]
58+
public function cp_login_endpoint_is_rate_limited()
59+
{
60+
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/login')->assertNotRateLimited());
61+
$this->post('/cp/auth/login')->assertRateLimited();
62+
}
63+
64+
#[Test]
65+
public function cp_password_email_endpoint_is_rate_limited()
66+
{
67+
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/password/email')->assertNotRateLimited());
68+
$this->post('/cp/auth/password/email')->assertRateLimited();
69+
}
70+
71+
#[Test]
72+
public function cp_password_reset_endpoint_is_rate_limited()
73+
{
74+
collect(range(1, 4))->each(fn () => $this->post('/cp/auth/password/reset')->assertNotRateLimited());
75+
$this->post('/cp/auth/password/reset')->assertRateLimited();
76+
}
77+
78+
#[Test]
79+
public function cp_and_frontend_auth_have_independent_buckets()
80+
{
81+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
82+
$this->post('/!/auth/login')->assertRateLimited();
83+
84+
$this->post('/cp/auth/login')->assertNotRateLimited();
85+
}
86+
87+
#[Test]
88+
public function auth_rate_limiter_can_be_overridden()
89+
{
90+
// Simulate a developer overriding the default 4/min limit to 2/min
91+
RateLimiter::for('statamic.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));
92+
93+
$this->post('/!/auth/login')->assertNotRateLimited();
94+
$this->post('/!/auth/login')->assertNotRateLimited();
95+
$this->post('/!/auth/login')->assertRateLimited();
96+
}
97+
98+
#[Test]
99+
public function cp_auth_rate_limiter_inherits_overrides_to_statamic_auth()
100+
{
101+
RateLimiter::for('statamic.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));
102+
103+
$this->post('/cp/auth/login')->assertNotRateLimited();
104+
$this->post('/cp/auth/login')->assertNotRateLimited();
105+
$this->post('/cp/auth/login')->assertRateLimited();
106+
}
107+
108+
#[Test]
109+
public function cp_auth_rate_limiter_can_be_overridden_independently()
110+
{
111+
RateLimiter::for('statamic.cp.auth', fn ($request) => Limit::perMinute(2)->by($request->ip()));
112+
113+
$this->post('/cp/auth/login')->assertNotRateLimited();
114+
$this->post('/cp/auth/login')->assertNotRateLimited();
115+
$this->post('/cp/auth/login')->assertRateLimited();
116+
117+
// Frontend auth still uses the default 4/min
118+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
119+
}
120+
121+
#[Test]
122+
public function forms_rate_limiter_can_be_overridden()
123+
{
124+
// Simulate a developer overriding the default 10/min limit to 2/min
125+
RateLimiter::for('statamic.forms', fn ($request) => Limit::perMinute(2)->by($request->ip()));
126+
127+
$this->post('/!/forms/contact')->assertNotRateLimited();
128+
$this->post('/!/forms/contact')->assertNotRateLimited();
129+
$this->post('/!/forms/contact')->assertRateLimited();
130+
}
131+
132+
#[Test]
133+
public function passkey_endpoint_is_rate_limited()
134+
{
135+
collect(range(1, 30))->each(fn () => $this->post('/!/auth/passkeys/auth')->assertNotRateLimited());
136+
$this->post('/!/auth/passkeys/auth')->assertRateLimited();
137+
}
138+
139+
#[Test]
140+
public function cp_passkey_endpoint_is_rate_limited()
141+
{
142+
collect(range(1, 30))->each(fn () => $this->post('/cp/auth/passkeys')->assertNotRateLimited());
143+
$this->post('/cp/auth/passkeys')->assertRateLimited();
144+
}
145+
146+
#[Test]
147+
public function cp_and_frontend_passkeys_have_independent_buckets()
148+
{
149+
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));
150+
151+
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
152+
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
153+
$this->post('/!/auth/passkeys/auth')->assertRateLimited();
154+
155+
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
156+
}
157+
158+
#[Test]
159+
public function passkeys_bucket_is_independent_from_auth_bucket()
160+
{
161+
collect(range(1, 4))->each(fn () => $this->post('/!/auth/login')->assertNotRateLimited());
162+
$this->post('/!/auth/login')->assertRateLimited();
163+
164+
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
165+
}
166+
167+
#[Test]
168+
public function passkeys_rate_limiter_can_be_overridden()
169+
{
170+
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));
171+
172+
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
173+
$this->post('/!/auth/passkeys/auth')->assertNotRateLimited();
174+
$this->post('/!/auth/passkeys/auth')->assertRateLimited();
175+
}
176+
177+
#[Test]
178+
public function cp_passkeys_rate_limiter_inherits_overrides_to_statamic_passkeys()
179+
{
180+
RateLimiter::for('statamic.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));
181+
182+
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
183+
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
184+
$this->post('/cp/auth/passkeys')->assertRateLimited();
185+
}
186+
187+
#[Test]
188+
public function cp_passkeys_rate_limiter_can_be_overridden_independently()
189+
{
190+
RateLimiter::for('statamic.cp.passkeys', fn ($request) => Limit::perMinute(2)->by($request->ip()));
191+
192+
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
193+
$this->post('/cp/auth/passkeys')->assertNotRateLimited();
194+
$this->post('/cp/auth/passkeys')->assertRateLimited();
195+
196+
// Frontend passkey still uses the default 30/min
197+
collect(range(1, 30))->each(fn () => $this->post('/!/auth/passkeys/auth')->assertNotRateLimited());
198+
}
199+
}

tests/TestCase.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ protected function setUp(): void
4646
}
4747

4848
$this->addGqlMacros();
49+
$this->addRateLimitMacros();
4950
}
5051

5152
public function tearDown(): void
@@ -281,6 +282,21 @@ private function addGqlMacros()
281282
});
282283
}
283284

285+
private function addRateLimitMacros()
286+
{
287+
TestResponse::macro('assertRateLimited', function () {
288+
Assert::assertSame(429, $this->getStatusCode(), 'Expected request to be rate limited, but it was not.');
289+
290+
return $this;
291+
});
292+
293+
TestResponse::macro('assertNotRateLimited', function () {
294+
Assert::assertNotSame(429, $this->getStatusCode(), 'Expected request not to be rate limited, but it was.');
295+
296+
return $this;
297+
});
298+
}
299+
284300
public function __call($name, $arguments)
285301
{
286302
if ($name == 'assertStringEqualsStringIgnoringLineEndings') {

0 commit comments

Comments
 (0)