Fix stale-CSRF login recovery render callback

pull/7230/head
Your Name 1 week ago
parent fbd52dd8fc
commit c9c897c4e7

@ -46,6 +46,7 @@ use Laravel\Passport\Http\Middleware\CheckTokenForAnyScope;
use Laravel\Passport\Http\Middleware\CreateFreshApiToken; use Laravel\Passport\Http\Middleware\CreateFreshApiToken;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
use League\OAuth2\Server\Exception\OAuthServerException; use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
@ -161,7 +162,18 @@ return Application::configure(basePath: dirname(__DIR__))
// A login-flow form submitted from a tab whose CSRF token is stale // A login-flow form submitted from a tab whose CSRF token is stale
// (the session was regenerated by a login or a pending step elsewhere) // (the session was regenerated by a login or a pending step elsewhere)
// lands on wherever the session actually is instead of a 419. // lands on wherever the session actually is instead of a 419.
$exceptions->render(function (TokenMismatchException $e, Request $request) { //
// The handler rewrites TokenMismatchException into an HttpException(419)
// (keeping the original as the previous exception) in prepareException()
// before render callbacks run, so this must key on the rewritten type
// and identify the CSRF case via status code + previous exception.
// Returning null for anything else lets the JSON Throwable callback below
// continue to handle XHR/JSON login requests as a 419.
$exceptions->render(function (HttpException $e, Request $request) {
if ($e->getStatusCode() !== 419 || ! $e->getPrevious() instanceof TokenMismatchException) {
return null;
}
if ($request->wantsJson() || ! $request->is('login', 'login/*')) { if ($request->wantsJson() || ! $request->is('login', 'login/*')) {
return null; return null;
} }

@ -0,0 +1,114 @@
<?php
use App\Models\User;
use App\Services\PendingLoginService;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Contracts\Session\Session;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Session\TokenMismatchException;
use Symfony\Component\HttpFoundation\Response;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| Stale CSRF login recovery
|--------------------------------------------------------------------------
|
| The multi-step login flow (credentials -> 2fa / email verify) regenerates
| the session on each step transition, which invalidates the CSRF token held
| by other tabs / back-button forms. A stale-token POST to a login endpoint
| must be recovered into a friendly redirect back to the correct step instead
| of the stock Symfony 419 "Page Expired" page.
|
| The framework's exception handler rewrites TokenMismatchException into an
| HttpException(419) in prepareException() BEFORE user render callbacks are
| dispatched, so the recovery callback must be keyed on the rewritten type and
| identify the CSRF case via status code + previous exception. These tests
| drive the handler directly because PreventRequestForgery bypasses CSRF while
| running unit tests, so a kernel-level POST can never raise a real mismatch.
|
*/
/**
* Render an exception through the application's real exception handler for a
* given request, exercising prepareException() + renderViaCallbacks().
*/
function renderThroughHandler(Request $request, Throwable $e): Response
{
app()->instance('request', $request);
return app(ExceptionHandler::class)->render($request, $e);
}
/**
* Build a browser POST request to a login endpoint that carries the same kind
* of stale token a second tab would submit. Returns the request together with
* its Laravel session store so callers can seed pending-login state.
*
* @return array{0: Request, 1: Session}
*/
function stalePost(string $uri): array
{
$session = app('session.store');
$request = Request::create($uri, 'POST', ['_token' => 'stale-token']);
$request->headers->set('Accept', 'text/html');
$request->setLaravelSession($session);
return [$request, $session];
}
it('redirects a stale-CSRF browser POST /login to the login form with a message', function () {
[$request] = stalePost('/login');
$response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.'));
expect($response)->toBeInstanceOf(RedirectResponse::class)
->and($response->getStatusCode())->toBe(302)
->and($response->getTargetUrl())->toContain('/login');
expect(session()->get('errors')?->get('login'))
->toContain('Your sign-in session expired. Please sign in again.');
});
it('redirects a stale-CSRF browser POST /login/2fa back to the pending step', function () {
$user = User::factory()->create();
[$request, $session] = stalePost('/login/2fa');
$session->put(PendingLoginService::SESSION_KEY, [
'user_id' => $user->id,
'email' => $user->email,
'remember' => false,
'step' => PendingLoginService::STEP_2FA,
'attempts' => 0,
'expires_at' => now()->addSeconds(PendingLoginService::TTL_SECONDS)->getTimestamp(),
]);
$response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.'));
expect($response)->toBeInstanceOf(RedirectResponse::class)
->and($response->getTargetUrl())->toContain('step='.PendingLoginService::STEP_2FA);
});
it('leaves a JSON/XHR stale-CSRF POST /login as a 419 for the JSON handler', function () {
$request = Request::create('/login', 'POST', ['_token' => 'stale-token']);
$request->headers->set('Accept', 'application/json');
$request->headers->set('X-Requested-With', 'XMLHttpRequest');
$request->setLaravelSession(app('session.store'));
$response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.'));
expect($response)->not->toBeInstanceOf(RedirectResponse::class)
->and($response->getStatusCode())->toBe(419);
});
it('does not intercept a non-login stale-CSRF POST', function () {
[$request] = stalePost('/some/other/form');
$response = renderThroughHandler($request, new TokenMismatchException('CSRF token mismatch.'));
expect($response)->not->toBeInstanceOf(RedirectResponse::class)
->and($response->getStatusCode())->toBe(419);
});
Loading…
Cancel
Save