fix(relay): don't crash request on corrupt cookies (#48)

* fix(relay): don't crash request on corrupt cookies

* style: apply php style fixes
This commit is contained in:
Mazen Touati
2026-01-25 20:52:43 +01:00
committed by GitHub
parent 33e1ff0c72
commit 0a4d44448b
3 changed files with 203 additions and 2 deletions

View File

@@ -12,6 +12,8 @@ class InvalidAuthorizationValueException extends RuntimeException
public const USER_IS_NOT_FOUND = 3;
public const REMEMBER_ME_COOKIE_IS_CORRUPT = 4;
public static function becauseBearerTokenValueIsNotString(): self
{
return new self(
@@ -35,4 +37,12 @@ class InvalidAuthorizationValueException extends RuntimeException
code: self::USER_IS_NOT_FOUND,
);
}
public static function becauseCookieIsNotDecryptable(): self
{
return new self(
message: 'The Current User remember me cookie is corrupt. Reload the page, or pick a different authorization method.',
code: self::REMEMBER_ME_COOKIE_IS_CORRUPT,
);
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Container\Container;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Session\SessionManager;
@@ -13,6 +14,7 @@ use Illuminate\Session\Store;
use Illuminate\Support\Arr;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Concerns\UsesSpecialAuthenticationInjector;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
/**
* Authorization handler that forwards the current user's session cookies.
@@ -71,6 +73,8 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
/**
* Attempt to retrieve the authenticated user from the Laravel session cookie.
*
* @throws InvalidAuthorizationValueException
*/
private function getUserFromSession(): ?Authenticatable
{
@@ -83,7 +87,14 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
/** @var Store $session */
$session = $this->container->make(SessionManager::class)->driver();
$session->setId(id: $this->extractSessionIdFromCookie($sessionCookie));
try {
$sessionId = $this->extractSessionIdFromCookie($sessionCookie);
} catch (DecryptException) {
throw InvalidAuthorizationValueException::becauseCookieIsNotDecryptable();
}
$session->setId(id: $sessionId);
$session->start();
$tokenPrefix = 'login_'.($this->projectManager->getAuthGuard());
@@ -101,6 +112,8 @@ class CurrentUserAuthorizationHandler implements AuthorizationHandler
*
* Laravels session cookie is formatted as "payload|signature", where the
* payload contains the encrypted session identifier.
*
* @throws \Illuminate\Contracts\Encryption\DecryptException
*/
private function extractSessionIdFromCookie(string $cookieValue): string
{

View File

@@ -3,10 +3,16 @@
namespace Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Request;
use Illuminate\Session\SessionManager;
use Illuminate\Session\Store;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\CoversMethod;
use Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Exceptions\InvalidAuthorizationValueException;
use Sunchayn\Nimbus\Modules\Relay\Authorization\Handlers\CurrentUserAuthorizationHandler;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Shared\HandlesRecallerCookies;
use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummyAuthenticatable;
@@ -14,6 +20,7 @@ use Sunchayn\Nimbus\Tests\App\Modules\Relay\Authorization\Handlers\Stubs\DummySp
use Sunchayn\Nimbus\Tests\TestCase;
#[CoversClass(CurrentUserAuthorizationHandler::class)]
#[CoversMethod(InvalidAuthorizationValueException::class, 'becauseCookieIsNotDecryptable')]
class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase
{
use HandlesRecallerCookies;
@@ -22,7 +29,7 @@ class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase
{
// Arrange
$this->mock(\Sunchayn\Nimbus\Modules\Config\ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$mock->shouldReceive('getAuthGuard')->andReturn($guardName = fake()->word());
$mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class);
});
@@ -99,4 +106,175 @@ class CurrentUserAuthorizationHandlerFunctionalTest extends TestCase
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
public function test_it_retrieves_user_from_session_cookie_when_request_user_is_null(): void
{
// Arrange
$this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$mock->shouldReceive('getAuthGuard')->andReturn($guardName = 'web');
$mock->shouldReceive('getSpecialAuthInjector')->andReturn(DummySpecialAuthenticationInjector::class);
});
$dummyAuthenticatable = new DummyAuthenticatable(id: $userId = fake()->randomNumber());
$this->mockAuthManagerToUseDummyModel($userId, $dummyAuthenticatable, $guardName);
$encrypter = resolve('encrypter');
$sessionId = fake()->uuid();
$sessionCookieName = config('session.cookie');
// Create encrypted session cookie value
$cookieValue = $encrypter->encrypt($sessionId.'|'.$sessionId, serialize: false);
$relayRequest = Request::create('ping');
$relayRequest->cookies->set($sessionCookieName, $cookieValue);
$relayRequest->setUserResolver(fn () => null);
// Mock session to return user ID
$sessionMock = $this->mock(Store::class);
$sessionMock->shouldReceive('setId')->with($sessionId)->once();
$sessionMock->shouldReceive('start')->once();
$sessionMock->shouldReceive('all')->andReturn([
'login_web' => $userId,
'other_key' => 'other_value',
]);
$sessionManagerMock = $this->mock(SessionManager::class);
$sessionManagerMock->shouldReceive('driver')->andReturn($sessionMock);
$dummySpecialAuthenticationInjectorMock = $this->mock(DummySpecialAuthenticationInjector::class);
$handler = resolve(CurrentUserAuthorizationHandler::class, [
'relayRequest' => $relayRequest,
]);
$pendingRequest = resolve(PendingRequest::class);
// Anticipate
$dummySpecialAuthenticationInjectorMock
->shouldReceive('attach')
->withAnyArgs()
->andReturn($pendingRequest);
// Act
$responsePendingRequest = $handler->authorize($pendingRequest);
// Assert
$this->assertSame($pendingRequest, $responsePendingRequest);
$dummySpecialAuthenticationInjectorMock
->shouldHaveReceived('attach')
->once()
->withArgs(function (PendingRequest $pendingRequestArg, Authenticatable $authenticatable) use ($pendingRequest, $dummyAuthenticatable) {
$this->assertSame($dummyAuthenticatable, $authenticatable);
$this->assertSame($pendingRequestArg, $pendingRequest);
return true;
});
}
public function test_it_returns_unmodified_request_when_session_cookie_is_invalid(): void
{
// Arrange
$sessionCookieName = config('session.cookie');
$relayRequest = Request::create('ping');
$relayRequest->cookies->set($sessionCookieName, 123); // Non-string cookie value
$relayRequest->setUserResolver(fn () => null);
$handler = resolve(CurrentUserAuthorizationHandler::class, [
'relayRequest' => $relayRequest,
]);
$pendingRequest = resolve(PendingRequest::class);
// Act
$pendingRequestResponse = $handler->authorize($pendingRequest);
// Assert
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
public function test_it_throws_exception_when_session_cookie_decryption_fails(): void
{
// Arrange
$sessionCookieName = config('session.cookie');
$relayRequest = Request::create('ping');
$relayRequest->cookies->set($sessionCookieName, 'invalid-encrypted-value');
$relayRequest->setUserResolver(fn () => null);
$handler = resolve(CurrentUserAuthorizationHandler::class, [
'relayRequest' => $relayRequest,
]);
$pendingRequest = resolve(PendingRequest::class);
// Assert
$this->expectException(InvalidAuthorizationValueException::class);
$this->expectExceptionCode(InvalidAuthorizationValueException::REMEMBER_ME_COOKIE_IS_CORRUPT);
$this->expectExceptionMessage('The Current User remember me cookie is corrupt. Reload the page, or pick a different authorization method.');
// Act
$handler->authorize($pendingRequest);
}
public function test_it_returns_unmodified_request_when_no_user_found_in_session(): void
{
// Arrange
$this->mock(ActiveApplicationResolver::class, function (MockInterface $mock) use (&$guardName) {
$mock->shouldReceive('getAuthGuard')->andReturn($guardName = 'web');
});
$userProvider = $this->mock(UserProvider::class);
$userProvider->shouldReceive('retrieveById')->with(null)->andReturn(null);
$this->mockAuthManager($userProvider, $guardName);
$encrypter = resolve('encrypter');
$sessionId = fake()->uuid();
$sessionCookieName = config('session.cookie');
$cookieValue = $encrypter->encrypt($sessionId.'|'.$sessionId, serialize: false);
$relayRequest = Request::create('ping');
$relayRequest->cookies->set($sessionCookieName, $cookieValue);
$relayRequest->setUserResolver(fn () => null);
// Mock session with no login data
$sessionMock = $this->mock(Store::class);
$sessionMock->shouldReceive('setId')->with($sessionId)->once();
$sessionMock->shouldReceive('start')->once();
$sessionMock->shouldReceive('all')->andReturn([
'other_key' => 'other_value',
]);
$sessionManagerMock = $this->mock(SessionManager::class);
$sessionManagerMock->shouldReceive('driver')->andReturn($sessionMock);
$handler = resolve(CurrentUserAuthorizationHandler::class, [
'relayRequest' => $relayRequest,
]);
$pendingRequest = resolve(PendingRequest::class);
// Act
$pendingRequestResponse = $handler->authorize($pendingRequest);
// Assert
$this->assertSame($pendingRequest, $pendingRequestResponse);
}
}