CVE-2025-48951
Description
Auth0-PHP is a PHP SDK for Auth0 Authentication and Management APIs. Versions 8.0.0-BETA3 prior to 8.3.1 contain a vulnerability due to insecure deserialization of cookie data. If exploited, since SDKs process cookie content without prior authentication, a threat actor could send a specially crafted cookie containing malicious serialized data. Applications using the Auth0-PHP SDK are affected, as are applications using the Auth0/symfony, Auth0/laravel-auth0, or Auth0/wordpress SDKs, because those SDKsrely on the Auth0-PHP SDK versions from 8.0.0-BETA3 until 8.14.0. Version 8.3.1 contains a patch for the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
auth0/auth0-phpPackagist | >= 8.0.0-BETA3, < 8.3.1 | 8.3.1 |
Patches
2848c702e2ea504b1f5daa8bd[SDK-3646] Reliability and performance improvements to CookieStore (#649)
5 files changed · +181 −110
composer.json+1 −1 modified@@ -50,7 +50,7 @@ "pestphp/pest": "^1.21", "php-http/mock-client": "^1.5", "phpstan/phpstan": "^1.7", - "phpstan/phpstan-strict-rules": "^1.3", + "phpstan/phpstan-strict-rules": "1.4.3", "phpunit/phpunit": "^9.5", "rector/rector": "^0.13.6", "squizlabs/php_codesniffer": "^3.7",
src/Store/CookieStore.php+121 −71 modified@@ -14,9 +14,8 @@ */ final class CookieStore implements StoreInterface { - public const USE_CRYPTO = true; public const KEY_HASHING_ALGO = 'sha256'; - public const KEY_CHUNKING_THRESHOLD = 3072; + public const KEY_CHUNKING_THRESHOLD = 2048; public const KEY_SEPARATOR = '_'; public const VAL_CRYPTO_ALGO = 'aes-128-gcm'; @@ -48,6 +47,16 @@ final class CookieStore implements StoreInterface */ private bool $deferring = false; + /** + * Determine if changes have been made since the last setState. + */ + private bool $dirty = false; + + /** + * Determine if changes have been made since the last setState. + */ + private bool $encrypt = true; + /** * CookieStore constructor. * @@ -68,13 +77,7 @@ public function __construct( $this->configuration = $configuration; $this->namespace = (string) $namespace; - - // @phpstan-ignore-next-line - if (self::USE_CRYPTO) { - $this->namespace = hash(self::KEY_HASHING_ALGO, $this->namespace); - } - - $this->threshold = self::KEY_CHUNKING_THRESHOLD - strlen($this->namespace) + 2; + $this->threshold = self::KEY_CHUNKING_THRESHOLD - strlen($this->namespace); $this->getState(); } @@ -95,6 +98,25 @@ public function getThreshold(): int return $this->threshold; } + /** + * Returns the current encryption state + */ + public function getEncrypted(): bool + { + return $this->encrypt; + } + + /** + * Toggle the encryption state + * + * @param bool $encrypt Enable or disable cookie encryption. + */ + public function setEncrypted(bool $encrypt = true): self + { + $this->encrypt = $encrypt; + return $this; + } + /** * Defer saving state changes to destination to improve performance during blocks of changes. * @@ -103,14 +125,13 @@ public function getThreshold(): int public function defer( bool $deferring ): void { + $this->deferring = $deferring; + // If we were deferring state saving and we've been asked to cancel that deference - if ($this->deferring && ! $deferring) { + if (! $deferring) { // Immediately push the state to the host device. $this->setState(); } - - // Update our deference state. - $this->deferring = $deferring; } /** @@ -125,6 +146,10 @@ public function getState( ): array { // Overwrite our internal state with one passed (presumably during unit tests.) if ($state !== null) { + if ($this->store !== $state) { + $this->dirty = true; + } + return $this->store = $state; } @@ -151,7 +176,7 @@ public function getState( } // If no cookies were found, set an empty state and continue. - if (mb_strlen($data) === 0) { + if ($data === '') { return $this->store = []; } @@ -171,9 +196,16 @@ public function getState( /** * Push our storage state to the source for persistence. + * + * @psalm-suppress UnusedFunctionCall */ - public function setState(): self - { + public function setState( + bool $force = false + ): self { + if (!$this->dirty && !$force) { + return $this; + } + $setOptions = $this->getCookieOptions(); $deleteOptions = $this->getCookieOptions(-1000); $existing = []; @@ -212,7 +244,7 @@ public function setState(): self // @codeCoverageIgnoreStart if (! defined('AUTH0_TESTS_DIR')) { /** @var array{expires?: int, path?: string, domain?: string, secure?: bool, httponly?: bool, samesite?: 'Lax'|'lax'|'None'|'none'|'Strict'|'strict', url_encode?: int} $setOptions */ - setcookie($cookieName, $chunk, $setOptions); + setrawcookie($cookieName, $chunk, $setOptions); } // @codeCoverageIgnoreEnd @@ -233,14 +265,15 @@ public function setState(): self // @codeCoverageIgnoreStart if (! defined('AUTH0_TESTS_DIR')) { /** @var array{expires?: int, path?: string, domain?: string, secure?: bool, httponly?: bool, samesite?: 'Lax'|'lax'|'None'|'none'|'Strict'|'strict', url_encode?: int} $deleteOptions */ - setcookie($cookieName, '', $deleteOptions); + setrawcookie($cookieName, '', $deleteOptions); } // @codeCoverageIgnoreEnd // Clear PHP's internal COOKIE global of the orphaned cookie. unset($_COOKIE[$cookieName]); } + $this->dirty = false; return $this; } @@ -260,7 +293,10 @@ public function set( [$key, \Auth0\SDK\Exception\ArgumentException::missing('key')], ])->isString(); - $this->store[(string) $key] = $value; + if (! isset($this->store[(string) $key]) || $this->store[(string) $key] !== $value) { + $this->store[(string) $key] = $value; + $this->dirty = true; + } if (! $this->deferring) { $this->setState(); @@ -303,7 +339,10 @@ public function delete( [$key, \Auth0\SDK\Exception\ArgumentException::missing('key')], ])->isString(); - unset($this->store[(string) $key]); + if (isset($this->store[(string) $key])) { + unset($this->store[(string) $key]); + $this->dirty = true; + } if (! $this->deferring) { $this->setState(); @@ -315,7 +354,10 @@ public function delete( */ public function purge(): void { - $this->store = []; + if ($this->store !== []) { + $this->store = []; + $this->dirty = true; + } if (! $this->deferring) { $this->setState(); @@ -351,10 +393,9 @@ public function getCookieOptions( $options['samesite'] = 'Lax'; } - $domain = $this->configuration->getCookieDomain() ?? $_SERVER['HTTP_HOST'] ?? null; + $domain = $this->configuration->getCookieDomain() ?? null; - if ($domain !== null) { - /** @var string $domain */ + if ($domain !== null && $domain !== $_SERVER['HTTP_HOST']) { $options['domain'] = $domain; } @@ -364,47 +405,70 @@ public function getCookieOptions( /** * Encrypt data for safe storage format for a cookie. * - * @param array<mixed> $data Data to encrypt. + * @param array<mixed> $data Data to encrypt. + * @param array<mixed> $options Additional configuration options. * * @psalm-suppress TypeDoesNotContainType */ - private function encrypt( - array $data + public function encrypt( + array $data, + array $options = [] ): string { - // @codeCoverageIgnoreStart - // @phpstan-ignore-next-line - if (! self::USE_CRYPTO) { - return base64_encode(json_encode(serialize($data), JSON_THROW_ON_ERROR)); + if (! $this->encrypt) { + $data = $options['encoded1'] ?? json_encode($data); + + if (! is_string($data)) { + return ''; + } + + return rawurlencode($data); } - // @codeCoverageIgnoreEnd $secret = $this->configuration->getCookieSecret(); - $ivLen = openssl_cipher_iv_length(self::VAL_CRYPTO_ALGO); + $ivLen = $options['ivLen'] ?? openssl_cipher_iv_length(self::VAL_CRYPTO_ALGO); + $tag = null; if ($secret === null) { throw \Auth0\SDK\Exception\ConfigurationException::requiresCookieSecret(); } - // @codeCoverageIgnoreStart - if ($ivLen === false) { + if (! is_int($ivLen)) { + return ''; + } + + $iv = $options['iv'] ?? openssl_random_pseudo_bytes($ivLen); + + if (! is_string($iv)) { + return ''; + } + + $data = $options['encoded1'] ?? json_encode($data); + + if (! is_string($data)) { return ''; } - // @codeCoverageIgnoreEnd - $iv = openssl_random_pseudo_bytes($ivLen); + // Encrypt the PHP array. + $encrypted = $options['encrypted'] ?? openssl_encrypt($data, self::VAL_CRYPTO_ALGO, $secret, 0, $iv, $tag); + $iv = $options['iv'] ?? $iv; + $tag = $options['tag'] ?? $tag; - // @codeCoverageIgnoreStart - // @phpstan-ignore-next-line - if ($iv === false) { + if (! is_string($encrypted)) { return ''; } - // @codeCoverageIgnoreEnd - // Encrypt the serialized PHP array. - $encrypted = openssl_encrypt(serialize($data), self::VAL_CRYPTO_ALGO, $secret, 0, $iv, $tag); + if (! is_string($tag)) { + return ''; + } // Return a JSON encoded object containing the crypto tag and iv, and the encrypted data. - return json_encode(serialize(['tag' => base64_encode($tag), 'iv' => base64_encode($iv), 'data' => $encrypted]), JSON_THROW_ON_ERROR); + $encoded = $options['encoded2'] ?? json_encode(['tag' => base64_encode($tag), 'iv' => base64_encode($iv), 'data' => $encrypted]); + + if (is_string($encoded)) { + return rawurlencode($encoded); + } + + return ''; } /** @@ -416,30 +480,19 @@ private function encrypt( * * @psalm-suppress TypeDoesNotContainType */ - private function decrypt( + public function decrypt( string $data ) { - // @codeCoverageIgnoreStart - // @phpstan-ignore-next-line - if (! self::USE_CRYPTO) { - $returns = []; - $decoded = base64_decode($data, true); - - if (is_string($decoded)) { - $decoded = json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); - } - - if (is_string($decoded)) { - $decoded = unserialize($decoded); - } + if (! $this->encrypt) { + $decoded = rawurldecode($data); + $decoded = json_decode($decoded, true); if (is_array($decoded)) { - $returns = $decoded; + return $decoded; } - return $returns; + return []; } - // @codeCoverageIgnoreEnd [$data] = Toolkit::filter([$data])->string()->trim(); @@ -453,14 +506,11 @@ private function decrypt( throw \Auth0\SDK\Exception\ConfigurationException::requiresCookieSecret(); } - $data = json_decode((string) $data, true); + $decoded = rawurldecode((string) $data); + $stripped = stripslashes($decoded); + $data = json_decode($stripped, true, 512); - if (! is_string($data)) { - return null; - } - - /** @var array{iv?: int|string|null, tag?: int|string|null, data: string} */ - $data = unserialize($data); + /** @var array{iv?: int|string|null, tag?: int|string|null, data: string} $data */ if (! isset($data['iv']) || ! isset($data['tag']) || ! is_string($data['iv']) || ! is_string($data['tag'])) { return null; @@ -469,17 +519,17 @@ private function decrypt( $iv = base64_decode($data['iv'], true); $tag = base64_decode($data['tag'], true); - if ($iv === false || $tag === false) { + if (! is_string($iv) || ! is_string($tag)) { return null; } $data = openssl_decrypt($data['data'], self::VAL_CRYPTO_ALGO, $secret, 0, $iv, $tag); - if ($data === false) { + if (! is_string($data)) { return null; } - $data = unserialize($data); + $data = json_decode($data, true); /** @var array<mixed> $data */ return $data;
tests/Unit/Store/CookieStoreTest.php+45 −19 modified@@ -27,10 +27,6 @@ $_COOKIE = []; }); -it('hashes the namespace', function(): void { - $this->assertNotEquals($this->namespace, $this->store->getNamespace()); -}); - it('calculates a chunking threshold', function(): void { expect($this->store->getThreshold())->toBeGreaterThan(0); }); @@ -46,7 +42,8 @@ it('populates state from $_COOKIE correctly', function(array $state): void { $cookieNamespace = $this->store->getNamespace() . '_0'; - $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, serialize([$this->exampleKey => $state])); + $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, [$this->exampleKey => $state]); + $_COOKIE[$cookieNamespace] = $encrypted; $this->store->getState(); @@ -57,7 +54,7 @@ ]]); it('populates state from a chunked $_COOKIE correctly', function(array $state): void { - $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, serialize([$this->exampleKey => $state])); + $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, [$this->exampleKey => $state]); $chunks = str_split($encrypted, 32); foreach($chunks as $index => $chunk) { @@ -86,7 +83,7 @@ it('does not populate state from an unencrypted $_COOKIE', function(array $state): void { $cookieNamespace = $this->store->getNamespace() . '_0'; - $_COOKIE[$cookieNamespace] = json_encode(serialize([$this->exampleKey => $state])); + $_COOKIE[$cookieNamespace] = json_encode([$this->exampleKey => $state]); $this->store->getState(); @@ -118,14 +115,15 @@ test('delete() updates state and $_COOKIE', function(array $state): void { $cookieNamespace = $this->store->getNamespace() . '_0'; - $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, serialize([$this->exampleKey => $state])); + $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, [$this->exampleKey => $state]); $_COOKIE[$cookieNamespace] = $encrypted; $this->store->getState(); $previousCookieState = $_COOKIE[$cookieNamespace]; - $this->store->setState(); + // Force the state change. As we didn't use the class methods to mutate the session state, it won't be flagged as dirty internally. + $this->store->setState(true); $this->assertNotEquals($_COOKIE[$cookieNamespace], $previousCookieState); @@ -140,7 +138,7 @@ test('purge() clears state and $_COOKIE', function(array $state): void { $cookieNamespace = $this->store->getNamespace() . '_0'; - $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, serialize([$this->exampleKey => $state])); + $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, [$this->exampleKey => $state]); $_COOKIE[$cookieNamespace] = $encrypted; $this->store->getState(); @@ -176,7 +174,7 @@ $this->store = new CookieStore($this->configuration, $this->namespace); $cookieNamespace = $this->store->getNamespace() . '_0'; - $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, serialize([$this->exampleKey => $state])); + $encrypted = MockCrypto::cookieCompatibleEncrypt($this->cookieSecret, [$this->exampleKey => $state]); $_COOKIE[$cookieNamespace] = $encrypted; $this->store->getState(); @@ -193,16 +191,16 @@ test('decrypt() returns null if a malformed cryptographic manifest is encoded', function(): void { $cookieNamespace = $this->store->getNamespace() . '_0'; - $_COOKIE[$cookieNamespace] = json_encode(serialize([ + $_COOKIE[$cookieNamespace] = json_encode([ 'tag' => uniqid() - ])); + ]); expect($this->store->getState())->toBeEmpty(); - $_COOKIE[$cookieNamespace] = json_encode(serialize([ + $_COOKIE[$cookieNamespace] = json_encode([ 'iv' => 'hi 👋 malformed cryptographic manifest here', 'tag' => (string) uniqid() - ])); + ]); expect($this->store->getState())->toBeEmpty(); }); @@ -212,29 +210,57 @@ $ivLength = openssl_cipher_iv_length(CookieStore::VAL_CRYPTO_ALGO); $iv = openssl_random_pseudo_bytes($ivLength); - $payload = json_encode(serialize([ + $payload = json_encode([ 'tag' => base64_encode((string) uniqid()), 'iv' => base64_encode($iv), 'data' => 'not encrypted :eyes:' - ]), JSON_THROW_ON_ERROR); + ], JSON_THROW_ON_ERROR); $_COOKIE[$cookieNamespace] = $payload; expect($this->store->getState())->toBeEmpty(); }); -test('Configured SameSite() is reflected', function(): void { +test('configured SameSite() is reflected', function(): void { $this->configuration->setCookieSameSite('strict'); $options = $this->store->getCookieOptions(); expect($options['samesite'])->toEqual('strict'); }); -test('Unsupported configured SameSite() is overwritten by default of `lax`', function(): void { +test('unsupported configured SameSite() is overwritten by default of `lax`', function(): void { $this->configuration->setCookieSameSite('testing'); $options = $this->store->getCookieOptions(); expect($options['samesite'])->toEqual('Lax'); }); + +test('toggling encryption works', function(array $state): void { + expect($this->store->getEncrypted())->toEqual(true); + + $this->store->setEncrypted(false); + expect($this->store->getEncrypted())->toEqual(false); + + $encrypted = $this->store->encrypt($state); + expect($encrypted)->toEqual(rawurlencode(json_encode($state))); + expect($this->store->decrypt($encrypted))->toEqual($state); + expect($this->store->decrypt(rawurlencode(json_encode('test'))))->toEqual([]); +})->with(['mocked state' => [ + fn() => MockDataset::state() +]]); + +test('encrypt() returns nothing with invalid crypto properties', function(): void { + $state = MockDataset::state(); + + expect($this->store->encrypt($state, ['ivLen' => false]))->toEqual(''); + expect($this->store->encrypt($state, ['iv' => false]))->toEqual(''); + expect($this->store->encrypt($state, ['tag' => false]))->toEqual(''); + expect($this->store->encrypt($state, ['encrypted' => false]))->toEqual(''); + expect($this->store->encrypt($state, ['encoded1' => false]))->toEqual(''); + expect($this->store->encrypt($state, ['encoded2' => false]))->toEqual(''); + + $this->store->setEncrypted(false); + expect($this->store->encrypt($state, ['encoded1' => false]))->toEqual(''); +});
tests/Utilities/MockCrypto.php+13 −8 modified@@ -16,21 +16,26 @@ class MockCrypto */ public static function cookieCompatibleEncrypt( string $secret, - string $data + $data, + ?string $overrideIv = null, + ?string $overrideTag = null ): string { $ivLength = openssl_cipher_iv_length(CookieStore::VAL_CRYPTO_ALGO); - $iv = openssl_random_pseudo_bytes($ivLength); - $encrypted = openssl_encrypt($data, CookieStore::VAL_CRYPTO_ALGO, $secret, 0, $iv, $tag); - $encrypted = json_encode(serialize([ - 'tag' => base64_encode($tag), + + $iv = $overrideIv ?? openssl_random_pseudo_bytes($ivLength); + + $encrypted = openssl_encrypt(json_encode($data), CookieStore::VAL_CRYPTO_ALGO, $secret, 0, $iv, $tag); + + $data = json_encode([ + 'tag' => base64_encode($overrideTag ?? $tag), 'iv' => base64_encode($iv), 'data' => $encrypted - ]), JSON_THROW_ON_ERROR); + ]); - if ($encrypted === false) { + if (! is_string($data)) { return ''; } - return $encrypted; + return rawurlencode($data); } }
tests/Utilities/MockDataset.php+1 −11 modified@@ -18,7 +18,7 @@ public static function state( int $depth = 0 ): array { $response = []; - $types = ['string', 'integer', 'float', 'boolean', 'array', 'object', 'null']; + $types = ['string', 'integer', 'float', 'boolean', 'array', 'null']; $childCount = random_int(count($types), count($types) * 2); for ($k = 0; $k < $childCount; $k++) { @@ -55,16 +55,6 @@ public static function state( continue; } - if ($type === 'object') { - if ($depth >= 1) { - $response[$name] = (object) []; - continue; - } - - $response[$name] = (object) self::state($depth + 1); - continue; - } - if ($type === null) { $response[$name] = null; continue;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-v9m8-9xxp-q492ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-48951ghsaADVISORY
- github.com/auth0/auth0-PHP/commit/04b1f5daa8bdfebc5e740ec5ca0fb2df1648a715nvdWEB
- github.com/auth0/auth0-PHP/security/advisories/GHSA-v9m8-9xxp-q492nvdWEB
- github.com/auth0/laravel-auth0/security/advisories/GHSA-c42h-56wx-h85qnvdWEB
- github.com/auth0/symfony/security/advisories/GHSA-98j6-67v3-mw34nvdWEB
- github.com/auth0/wordpress/security/advisories/GHSA-862m-5253-832rnvdWEB
News mentions
0No linked articles in our index yet.