use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
+use Illuminate\Support\Sleep;
class ForgotPasswordController extends Controller
{
'email' => ['required', 'email'],
]);
+ // Add random pause to the response to help avoid time-base sniffing
+ // of valid resets via slower email send handling.
+ Sleep::for(random_int(1000, 3000))->milliseconds();
+
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
class ResetPasswordController extends Controller
{
- protected LoginService $loginService;
-
- public function __construct(LoginService $loginService)
- {
+ public function __construct(
+ protected LoginService $loginService
+ ) {
$this->middleware('guest');
$this->middleware('guard:standard');
-
- $this->loginService = $loginService;
}
/**
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
+
+ RateLimiter::for('public', function (Request $request) {
+ return Limit::perMinute(10)->by($request->ip());
+ });
}
}
Route::get('/register/confirm/awaiting', [AccessControllers\ConfirmEmailController::class, 'showAwaiting']);
Route::post('/register/confirm/resend', [AccessControllers\ConfirmEmailController::class, 'resend']);
Route::get('/register/confirm/{token}', [AccessControllers\ConfirmEmailController::class, 'showAcceptForm']);
-Route::post('/register/confirm/accept', [AccessControllers\ConfirmEmailController::class, 'confirm']);
-Route::post('/register', [AccessControllers\RegisterController::class, 'postRegister']);
+Route::post('/register/confirm/accept', [AccessControllers\ConfirmEmailController::class, 'confirm'])->middleware('throttle:public');
+Route::post('/register', [AccessControllers\RegisterController::class, 'postRegister'])->middleware('throttle:public');
// SAML routes
Route::post('/saml2/login', [AccessControllers\Saml2Controller::class, 'login']);
Route::post('/oidc/logout', [AccessControllers\OidcController::class, 'logout']);
// User invitation routes
-Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword']);
-Route::post('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'setPassword']);
+Route::get('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'showSetPassword'])->middleware('throttle:public');
+Route::post('/register/invite/{token}', [AccessControllers\UserInviteController::class, 'setPassword'])->middleware('throttle:public');
// Password reset link request routes
Route::get('/password/email', [AccessControllers\ForgotPasswordController::class, 'showLinkRequestForm']);
-Route::post('/password/email', [AccessControllers\ForgotPasswordController::class, 'sendResetLinkEmail']);
+Route::post('/password/email', [AccessControllers\ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:public');
// Password reset routes
Route::get('/password/reset/{token}', [AccessControllers\ResetPasswordController::class, 'showResetForm']);
-Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset']);
+Route::post('/password/reset', [AccessControllers\ResetPasswordController::class, 'reset'])->middleware('throttle:public');
// Metadata routes
Route::view('/help/wysiwyg', 'help.wysiwyg');
$resp = $this->followRedirects($resp);
$this->withHtml($resp)->assertElementExists('form input[name="username"].text-neg');
}
+
+ public function test_registration_endpoint_throttled()
+ {
+ $this->setSettings(['registration-enabled' => 'true']);
+
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->post('/register/', [
+ 'name' => "Barry{$i}",
+ 'email' => "barry{$i}@example.com",
+ 'password' => "barryIsTheBest{$i}",
+ ]);
+ auth()->logout();
+ }
+
+ $response->assertStatus(429);
+ }
+
+ public function test_registration_confirmation_throttled()
+ {
+ $this->setSettings(['registration-enabled' => 'true']);
+
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->post('/register/confirm/accept', [
+ 'token' => "token{$i}",
+ ]);
+ }
+
+ $response->assertStatus(429);
+ }
}
use BookStack\Access\Notifications\ResetPasswordNotification;
use BookStack\Users\Models\User;
+use Carbon\CarbonInterval;
use Illuminate\Support\Facades\Notification;
+use Illuminate\Support\Sleep;
use Tests\TestCase;
class ResetPasswordTest extends TestCase
{
+ protected function setUp(): void
+ {
+ parent::setUp();
+ Sleep::fake();
+ }
+
public function test_reset_flow()
{
Notification::fake();
->assertSee('The password reset token is invalid for this email address.');
}
+ public function test_reset_request_with_not_found_user_still_has_delay()
+ {
+ $this->followingRedirects()->post('/password/email', [
+ ]);
+
+ Sleep::assertSlept(function (CarbonInterval $duration): bool {
+ return $duration->totalMilliseconds > 999;
+ }, 1);
+ }
+
public function test_reset_page_shows_sign_links()
{
$this->setSettings(['registration-enabled' => 'true']);
Notification::assertSentTimes(ResetPasswordNotification::class, 1);
$resp->assertSee('A password reset link will be sent to ' . $editor->email . ' if that email address is found in the system.');
}
+
+ public function test_reset_request_with_not_found_user_is_throttled()
+ {
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->post('/password/email', [
+ ]);
+ }
+
+ $response->assertStatus(429);
+ }
+
+ public function test_reset_call_is_throttled()
+ {
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->post('/password/reset', [
+ 'email' => "arandomuser{$i}@example.com",
+ 'token' => "randomtoken{$i}",
+ ]);
+ }
+
+ $response->assertStatus(429);
+ }
}
$setPasswordPageResp->assertRedirect('/password/email');
$setPasswordPageResp->assertSessionHas('error', 'This invitation link has expired. You can instead try to reset your account password.');
}
+
+ public function test_set_password_view_is_throttled()
+ {
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->get("/register/invite/tokenhere{$i}");
+ }
+
+ $response->assertStatus(429);
+ }
+
+ public function test_set_password_post_is_throttled()
+ {
+ for ($i = 0; $i < 11; $i++) {
+ $response = $this->post("/register/invite/tokenhere{$i}", [
+ 'password' => 'my test password',
+ ]);
+ }
+
+ $response->assertStatus(429);
+ }
}