CVE-2024-39912
Description
web-auth/webauthn-lib is an open source set of PHP libraries and a Symfony bundle to allow developers to integrate that authentication mechanism into their web applications. The ProfileBasedRequestOptionsBuilder method returns allowedCredentials without any credentials if no username was found. When WebAuthn is used as the first or only authentication method, an attacker can enumerate usernames based on the absence of the allowedCredentials property in the assertion options response. This allows enumeration of valid or invalid usernames. By knowing which usernames are valid, attackers can focus their efforts on a smaller set of potential targets, increasing the efficiency and likelihood of successful attacks. This issue has been addressed in version 4.9.0 and all users are advised to upgrade. There are no known workarounds for this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
web-auth/webauthn-libPackagist | >= 4.5.0, < 4.9.0 | 4.9.0 |
web-auth/webauthn-frameworkPackagist | >= 4.5.0, < 4.9.0 | 4.9.0 |
Patches
364de11f6cddcAdd feature to hide existing credentials
11 files changed · +52 −10
phpstan-baseline.neon+10 −5 modified@@ -692,6 +692,11 @@ parameters: count: 1 path: src/symfony/src/Controller/AttestationControllerFactory.php + - + message: "#^Method Webauthn\\\\Bundle\\\\CredentialOptionsBuilder\\\\PublicKeyCredentialCreationOptionsBuilder\\:\\:getFromRequest\\(\\) invoked with 3 parameters, 2 required\\.$#" + count: 1 + path: src/symfony/src/Controller/AttestationRequestController.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\HttpFoundation\\\\Request\\:\\:getContentType\\(\\)\\.$#" count: 1 @@ -807,11 +812,6 @@ parameters: count: 1 path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - - message: "#^Should not use function \"dump\", please change the code\\.$#" - count: 1 - path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - message: """ #^Fetching class constant class of deprecated class Webauthn\\\\Bundle\\\\Event\\\\AuthenticatorAssertionResponseValidationFailedEvent\\: @@ -1061,6 +1061,11 @@ parameters: count: 4 path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - + message: "#^Cannot access offset 'hide_existing…' on mixed\\.$#" + count: 1 + path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - message: "#^Cannot access offset 'host' on mixed\\.$#" count: 4
src/symfony/src/Controller/AttestationControllerFactory.php+3 −1 modified@@ -72,13 +72,15 @@ public function createRequestController( OptionsStorage $optionStorage, CreationOptionsHandler $creationOptionsHandler, FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + bool $hideExistingExcludedCredentials = false ): AttestationRequestController { return new AttestationRequestController( $optionsBuilder, $userEntityGuesser, $optionStorage, $creationOptionsHandler, - $failureHandler + $failureHandler, + $hideExistingExcludedCredentials ); }
src/symfony/src/Controller/AttestationRequestController.php+6 −1 modified@@ -24,14 +24,19 @@ public function __construct( private readonly OptionsStorage $optionsStorage, private readonly CreationOptionsHandler $creationOptionsHandler, private readonly FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + private readonly bool $hideExistingExcludedCredentials = false, ) { } public function __invoke(Request $request): Response { try { $userEntity = $this->userEntityGuesser->findUserEntity($request); - $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest($request, $userEntity); + $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest( + $request, + $userEntity, + $this->hideExistingExcludedCredentials + ); $response = $this->creationOptionsHandler->onCreationOptions( $publicKeyCredentialCreationOptions,
src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php+3 −2 modified@@ -48,7 +48,8 @@ public function __construct( public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + bool $hideExistingExcludedCredentials = false ): PublicKeyCredentialCreationOptions { $format = method_exists( $request, @@ -57,7 +58,7 @@ public function getFromRequest( $format === 'json' || throw new BadRequestHttpException('Only JSON content type allowed'); $content = $request->getContent(); - $excludedCredentials = $this->getCredentials($userEntity); + $excludedCredentials = $hideExistingExcludedCredentials === true ? [] : $this->getCredentials($userEntity); $optionsRequest = $this->getServerPublicKeyCredentialCreationOptionsRequest($content); $authenticatorSelectionData = $optionsRequest->authenticatorSelection; $authenticatorSelection = null;
src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php+2 −1 modified@@ -12,6 +12,7 @@ interface PublicKeyCredentialCreationOptionsBuilder { public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + /*bool $hideExistingExcludedCredentials = false*/ ): PublicKeyCredentialCreationOptions; }
src/symfony/src/DependencyInjection/Configuration.php+6 −0 modified@@ -354,6 +354,12 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void ->scalarNode('user_entity_guesser') ->isRequired() ->end() + ->scalarNode('hide_existing_credentials') + ->info( + 'In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users.' + ) + ->defaultFalse() + ->end() ->scalarNode('options_storage') ->defaultValue(SessionStorage::class) ->info('Service responsible of the options/user entity storage during the ceremony')
src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php+1 −0 modified@@ -415,6 +415,7 @@ private function createAttestationRequestControllerAndRoute( new Reference($optionsStorageId), new Reference($optionsHandlerId), new Reference($failureHandlerId), + true, ]); $this->createControllerAndRoute( $container,
src/symfony/src/DependencyInjection/WebauthnExtension.php+1 −0 modified@@ -215,6 +215,7 @@ private function loadCreationControllersSupport(ContainerBuilder $container, arr new Reference($creationConfig['options_storage']), new Reference($creationConfig['options_handler']), new Reference($creationConfig['failure_handler']), + $creationConfig['hide_existing_credentials'] ?? false, ]) ->addTag(DynamicRouteCompilerPass::TAG, [ 'method' => $creationConfig['options_method'],
tests/symfony/config/config.yml+1 −0 modified@@ -131,6 +131,7 @@ webauthn: enabled: true creation: test: + hide_existing_credentials: true options_path: '/devices/add/options' result_path: '/devices/add' #host: null
tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php+1 −0 modified@@ -64,6 +64,7 @@ public function anExistingUserCanAskForOptionsUsingTheDedicatedController(): voi static::assertArrayHasKey($expectedKey, $data); } static::assertSame('ok', $data['status']); + static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled } #[Test]
tests/symfony/functional/PublicKeyCredentialSourceRepository.php+18 −0 modified@@ -38,6 +38,24 @@ public function __construct( 100 ); $this->saveCredentialSource($publicKeyCredentialSource1); + $publicKeyCredentialSource2 = PublicKeyCredentialSource::create( + base64_decode( + 'Ac8zKrpVWv9UCwxY1FyMqkESz2lV4CNwTk2+Hp19LgKbvh5uQ2/i6AMbTbTz1zcNapCEeiLJPlAAVM4L7AIow6I=', + true + ), + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + [], + AttestationStatement::TYPE_NONE, + EmptyTrustPath::create(), + Uuid::fromBinary(base64_decode('AAAAAAAAAAAAAAAAAAAAAA==', true)), + base64_decode( + 'pQECAyYgASFYIJV56vRrFusoDf9hm3iDmllcxxXzzKyO9WruKw4kWx7zIlgg/nq63l8IMJcIdKDJcXRh9hoz0L+nVwP1Oxil3/oNQYs=', + true + ), + '929fba2f-2361-4bc6-a917-bb76aa14c7f9', + 100 + ); + $this->saveCredentialSource($publicKeyCredentialSource2); } public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
a9d1352897fbMerge pull request #617 from web-auth/bugs/username-enumeration
11 files changed · +52 −10
phpstan-baseline.neon+10 −5 modified@@ -692,6 +692,11 @@ parameters: count: 1 path: src/symfony/src/Controller/AttestationControllerFactory.php + - + message: "#^Method Webauthn\\\\Bundle\\\\CredentialOptionsBuilder\\\\PublicKeyCredentialCreationOptionsBuilder\\:\\:getFromRequest\\(\\) invoked with 3 parameters, 2 required\\.$#" + count: 1 + path: src/symfony/src/Controller/AttestationRequestController.php + - message: "#^Call to an undefined method Symfony\\\\Component\\\\HttpFoundation\\\\Request\\:\\:getContentType\\(\\)\\.$#" count: 1 @@ -807,11 +812,6 @@ parameters: count: 1 path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - - message: "#^Should not use function \"dump\", please change the code\\.$#" - count: 1 - path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php - - message: """ #^Fetching class constant class of deprecated class Webauthn\\\\Bundle\\\\Event\\\\AuthenticatorAssertionResponseValidationFailedEvent\\: @@ -1061,6 +1061,11 @@ parameters: count: 4 path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - + message: "#^Cannot access offset 'hide_existing…' on mixed\\.$#" + count: 1 + path: src/symfony/src/DependencyInjection/WebauthnExtension.php + - message: "#^Cannot access offset 'host' on mixed\\.$#" count: 4
src/symfony/src/Controller/AttestationControllerFactory.php+3 −1 modified@@ -72,13 +72,15 @@ public function createRequestController( OptionsStorage $optionStorage, CreationOptionsHandler $creationOptionsHandler, FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + bool $hideExistingExcludedCredentials = false ): AttestationRequestController { return new AttestationRequestController( $optionsBuilder, $userEntityGuesser, $optionStorage, $creationOptionsHandler, - $failureHandler + $failureHandler, + $hideExistingExcludedCredentials ); }
src/symfony/src/Controller/AttestationRequestController.php+6 −1 modified@@ -24,14 +24,19 @@ public function __construct( private readonly OptionsStorage $optionsStorage, private readonly CreationOptionsHandler $creationOptionsHandler, private readonly FailureHandler|AuthenticationFailureHandlerInterface $failureHandler, + private readonly bool $hideExistingExcludedCredentials = false, ) { } public function __invoke(Request $request): Response { try { $userEntity = $this->userEntityGuesser->findUserEntity($request); - $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest($request, $userEntity); + $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest( + $request, + $userEntity, + $this->hideExistingExcludedCredentials + ); $response = $this->creationOptionsHandler->onCreationOptions( $publicKeyCredentialCreationOptions,
src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php+3 −2 modified@@ -48,7 +48,8 @@ public function __construct( public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + bool $hideExistingExcludedCredentials = false ): PublicKeyCredentialCreationOptions { $format = method_exists( $request, @@ -57,7 +58,7 @@ public function getFromRequest( $format === 'json' || throw new BadRequestHttpException('Only JSON content type allowed'); $content = $request->getContent(); - $excludedCredentials = $this->getCredentials($userEntity); + $excludedCredentials = $hideExistingExcludedCredentials === true ? [] : $this->getCredentials($userEntity); $optionsRequest = $this->getServerPublicKeyCredentialCreationOptionsRequest($content); $authenticatorSelectionData = $optionsRequest->authenticatorSelection; $authenticatorSelection = null;
src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php+2 −1 modified@@ -12,6 +12,7 @@ interface PublicKeyCredentialCreationOptionsBuilder { public function getFromRequest( Request $request, - PublicKeyCredentialUserEntity $userEntity + PublicKeyCredentialUserEntity $userEntity, + /*bool $hideExistingExcludedCredentials = false*/ ): PublicKeyCredentialCreationOptions; }
src/symfony/src/DependencyInjection/Configuration.php+6 −0 modified@@ -354,6 +354,12 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void ->scalarNode('user_entity_guesser') ->isRequired() ->end() + ->scalarNode('hide_existing_credentials') + ->info( + 'In order to prevent username enumeration, the existing credentials can be hidden. This is highly recommended when the attestation ceremony is performed by anonymous users.' + ) + ->defaultFalse() + ->end() ->scalarNode('options_storage') ->defaultValue(SessionStorage::class) ->info('Service responsible of the options/user entity storage during the ceremony')
src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php+1 −0 modified@@ -415,6 +415,7 @@ private function createAttestationRequestControllerAndRoute( new Reference($optionsStorageId), new Reference($optionsHandlerId), new Reference($failureHandlerId), + true, ]); $this->createControllerAndRoute( $container,
src/symfony/src/DependencyInjection/WebauthnExtension.php+1 −0 modified@@ -215,6 +215,7 @@ private function loadCreationControllersSupport(ContainerBuilder $container, arr new Reference($creationConfig['options_storage']), new Reference($creationConfig['options_handler']), new Reference($creationConfig['failure_handler']), + $creationConfig['hide_existing_credentials'] ?? false, ]) ->addTag(DynamicRouteCompilerPass::TAG, [ 'method' => $creationConfig['options_method'],
tests/symfony/config/config.yml+1 −0 modified@@ -131,6 +131,7 @@ webauthn: enabled: true creation: test: + hide_existing_credentials: true options_path: '/devices/add/options' result_path: '/devices/add' #host: null
tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php+1 −0 modified@@ -64,6 +64,7 @@ public function anExistingUserCanAskForOptionsUsingTheDedicatedController(): voi static::assertArrayHasKey($expectedKey, $data); } static::assertSame('ok', $data['status']); + static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled } #[Test]
tests/symfony/functional/PublicKeyCredentialSourceRepository.php+18 −0 modified@@ -38,6 +38,24 @@ public function __construct( 100 ); $this->saveCredentialSource($publicKeyCredentialSource1); + $publicKeyCredentialSource2 = PublicKeyCredentialSource::create( + base64_decode( + 'Ac8zKrpVWv9UCwxY1FyMqkESz2lV4CNwTk2+Hp19LgKbvh5uQ2/i6AMbTbTz1zcNapCEeiLJPlAAVM4L7AIow6I=', + true + ), + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + [], + AttestationStatement::TYPE_NONE, + EmptyTrustPath::create(), + Uuid::fromBinary(base64_decode('AAAAAAAAAAAAAAAAAAAAAA==', true)), + base64_decode( + 'pQECAyYgASFYIJV56vRrFusoDf9hm3iDmllcxxXzzKyO9WruKw4kWx7zIlgg/nq63l8IMJcIdKDJcXRh9hoz0L+nVwP1Oxil3/oNQYs=', + true + ), + '929fba2f-2361-4bc6-a917-bb76aa14c7f9', + 100 + ); + $this->saveCredentialSource($publicKeyCredentialSource2); } public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
b6798de27cdeAdd FakeCredentialGenerator for preventing username enumeration (#603)
2 files changed · +82 −0
src/FakeCredentialGenerator.php+15 −0 added@@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Webauthn; + +use Symfony\Component\HttpFoundation\Request; + +interface FakeCredentialGenerator +{ + /** + * @return PublicKeyCredentialDescriptor[] + */ + public function generate(Request $request, string $username): array; +}
src/SimpleFakeCredentialGenerator.php+67 −0 added@@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Webauthn; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\HttpFoundation\Request; +use function count; +use function is_int; + +final class SimpleFakeCredentialGenerator implements FakeCredentialGenerator +{ + public function __construct( + private readonly null|CacheItemPoolInterface $cache = null + ) { + } + + /** + * @return PublicKeyCredentialDescriptor[] + */ + public function generate(Request $request, string $username): array + { + if ($this->cache === null) { + return $this->generateCredentials($username); + } + + $cacheKey = 'fake_credentials_' . hash('xxh128', $username); + $cacheItem = $this->cache->getItem($cacheKey); + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + + $credentials = $this->generateCredentials($username); + $cacheItem->set($credentials); + $this->cache->save($cacheItem); + + return $credentials; + } + + /** + * @return PublicKeyCredentialDescriptor[] + */ + private function generateCredentials(string $username): array + { + $transports = [ + PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_USB, + PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_NFC, + PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_BLE, + ]; + $credentials = []; + for ($i = 0; $i < random_int(1, 3); $i++) { + $randomTransportKeys = array_rand($transports, random_int(1, count($transports))); + if (is_int($randomTransportKeys)) { + $randomTransportKeys = [$randomTransportKeys]; + } + $randomTransports = array_values(array_intersect_key($transports, array_flip($randomTransportKeys))); + $credentials[] = PublicKeyCredentialDescriptor::create( + PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY, + hash('sha256', random_bytes(16) . $username), + $randomTransports + ); + } + + return $credentials; + } +}
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
6- github.com/advisories/GHSA-875x-g8p7-5w27ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-39912ghsaADVISORY
- github.com/web-auth/webauthn-framework/commit/64de11f6cddc71e56c76e0cc4573bf94d02be045nvdWEB
- github.com/web-auth/webauthn-framework/commit/a9d1352897fba552e659e1445a771dec2d4ed05aghsaWEB
- github.com/web-auth/webauthn-framework/security/advisories/GHSA-875x-g8p7-5w27nvdWEB
- github.com/web-auth/webauthn-lib/commit/b6798de27cdedd8681fe4c9b13ace0ff2456d18bghsaWEB
News mentions
0No linked articles in our index yet.