CVE-2024-39912: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-07-15T20:15Z
Repository: https://github.com/web-auth/webauthn-framework
Language: PHP
Files changed: phpstan-baseline.neon, src/symfony/src/Controller/AttestationControllerFactory.php, src/symfony/src/Controller/AttestationRequestController.php, src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php, src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php, src/symfony/src/DependencyInjection/Configuration.php, src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php, src/symfony/src/DependencyInjection/WebauthnExtension.php, tests/symfony/config/config.yml, tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php, tests/symfony/functional/PublicKeyCredentialSourceRepository.php
Commit date: 2024-07-06 06:53:56 +0200
Commit message: Add feature to hide existing credentials

The code changes enable suppressing the existing user credential details to enhance security. This feature introduces a preventive measure against username enumeration exploits by concealing the previously existing credentials. The flag 'hide_existing_credentials' has been added to facilitate this change, defaulted to false. This change is particularly important during the attestation ceremony performed by anonymous users.

Diff: diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index ac589c1..bc26eb0 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -690,10 +690,15 @@ parameters:
 		-
 			message: "#^Parameter \\$publicKeyCredentialSourceRepository of method Webauthn\\\\Bundle\\\\Controller\\\\AttestationControllerFactory\\:\\:__construct\\(\\) has typehint with deprecated interface Webauthn\\\\PublicKeyCredentialSourceRepository\\.$#"
 			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
 			path: src/symfony/src/Controller/AttestationResponseController.php
 
@@ -805,15 +810,10 @@ parameters:
 		-
 			message: "#^Parameter \\$credentialSourceRepository of method Webauthn\\\\Bundle\\\\CredentialOptionsBuilder\\\\ProfileBasedRequestOptionsBuilder\\:\\:__construct\\(\\) has typehint with deprecated interface Webauthn\\\\PublicKeyCredentialSourceRepository\\.$#"
 			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\\:
 				since 4\\.3\\.0, use \\{@link \\\\Webauthn\\\\Event\\\\AuthenticatorAssertionResponseValidationFailedEvent\\} instead\\.$#
 			"""
@@ -1059,10 +1059,15 @@ parameters:
 		-
 			message: "#^Cannot access offset 'failure_handler' on mixed\\.$#"
 			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
 			path: src/symfony/src/DependencyInjection/WebauthnExtension.php
 
diff --git a/src/symfony/src/Controller/AttestationControllerFactory.php b/src/symfony/src/Controller/AttestationControllerFactory.php
index 286a067..d2fef6a 100644
--- a/src/symfony/src/Controller/AttestationControllerFactory.php
+++ b/src/symfony/src/Controller/AttestationControllerFactory.php
@@ -70,17 +70,19 @@ final class AttestationControllerFactory
         PublicKeyCredentialCreationOptionsBuilder $optionsBuilder,
         UserEntityGuesser $userEntityGuesser,
         OptionsStorage $optionStorage,
         CreationOptionsHandler $creationOptionsHandler,
         FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
+        bool $hideExistingExcludedCredentials = false
     ): AttestationRequestController {
         return new AttestationRequestController(
             $optionsBuilder,
             $userEntityGuesser,
             $optionStorage,
             $creationOptionsHandler,
-            $failureHandler
+            $failureHandler,
+            $hideExistingExcludedCredentials
         );
     }
 
     /**
      * @deprecated since 4.5.0 and will be removed in 5.0.0. Please use createResponseController instead.
diff --git a/src/symfony/src/Controller/AttestationRequestController.php b/src/symfony/src/Controller/AttestationRequestController.php
index 314fe14..70f86b5 100644
--- a/src/symfony/src/Controller/AttestationRequestController.php
+++ b/src/symfony/src/Controller/AttestationRequestController.php
@@ -22,18 +22,23 @@ final class AttestationRequestController
         private readonly PublicKeyCredentialCreationOptionsBuilder $extractor,
         private readonly UserEntityGuesser $userEntityGuesser,
         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,
                 $userEntity
             );
diff --git a/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php b/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php
index 9d1dd49..7b9a10f 100644
--- a/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php
+++ b/src/symfony/src/CredentialOptionsBuilder/ProfileBasedCreationOptionsBuilder.php
@@ -46,20 +46,21 @@ final class ProfileBasedCreationOptionsBuilder implements PublicKeyCredentialCre
         }
     }
 
     public function getFromRequest(
         Request $request,
-        PublicKeyCredentialUserEntity $userEntity
+        PublicKeyCredentialUserEntity $userEntity,
+        bool $hideExistingExcludedCredentials = false
     ): PublicKeyCredentialCreationOptions {
         $format = method_exists(
             $request,
             'getContentTypeFormat'
         ) ? $request->getContentTypeFormat() : $request->getContentType();
         $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;
         if (is_array($authenticatorSelectionData)) {
             $authenticatorSelection = AuthenticatorSelectionCriteria::createFromArray($authenticatorSelectionData);
diff --git a/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php b/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php
index 634d3ce..12e3c92 100644
--- a/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php
+++ b/src/symfony/src/CredentialOptionsBuilder/PublicKeyCredentialCreationOptionsBuilder.php
@@ -10,8 +10,9 @@ use Webauthn\PublicKeyCredentialUserEntity;
 
 interface PublicKeyCredentialCreationOptionsBuilder
 {
     public function getFromRequest(
         Request $request,
-        PublicKeyCredentialUserEntity $userEntity
+        PublicKeyCredentialUserEntity $userEntity,
+        /*bool $hideExistingExcludedCredentials = false*/
     ): PublicKeyCredentialCreationOptions;
 }
diff --git a/src/symfony/src/DependencyInjection/Configuration.php b/src/symfony/src/DependencyInjection/Configuration.php
index e060a5b..b103100 100644
--- a/src/symfony/src/DependencyInjection/Configuration.php
+++ b/src/symfony/src/DependencyInjection/Configuration.php
@@ -352,10 +352,16 @@ final class Configuration implements ConfigurationInterface
             ->defaultNull()
             ->end()
             ->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')
             ->end()
             ->scalarNode('success_handler')
diff --git a/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php b/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php
index 68eb78b..7daae1b 100644
--- a/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php
+++ b/src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php
@@ -413,10 +413,11 @@ final class WebauthnFactory implements FirewallListenerFactoryInterface, Authent
                 new Reference($optionsBuilderId),
                 new Reference(RequestBodyUserEntityGuesser::class),
                 new Reference($optionsStorageId),
                 new Reference($optionsHandlerId),
                 new Reference($failureHandlerId),
+                true,
             ]);
         $this->createControllerAndRoute(
             $container,
             $controller,
             'creation',
diff --git a/src/symfony/src/DependencyInjection/WebauthnExtension.php b/src/symfony/src/DependencyInjection/WebauthnExtension.php
index 7240f98..8c92df2 100644
--- a/src/symfony/src/DependencyInjection/WebauthnExtension.php
+++ b/src/symfony/src/DependencyInjection/WebauthnExtension.php
@@ -213,10 +213,11 @@ final class WebauthnExtension extends Extension implements PrependExtensionInter
                     new Reference($creationOptionsBuilderId),
                     new Reference($creationConfig['user_entity_guesser']),
                     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'],
                     'path' => $creationConfig['options_path'],
                     'host' => $creationConfig['host'],
diff --git a/tests/symfony/config/config.yml b/tests/symfony/config/config.yml
index e473f7d..d04ba5a 100644
--- a/tests/symfony/config/config.yml
+++ b/tests/symfony/config/config.yml
@@ -129,10 +129,11 @@ webauthn:
     api_key: 'test'
   controllers:
     enabled: true
     creation:
       test:
+        hide_existing_credentials: true
         options_path: '/devices/add/options'
         result_path: '/devices/add'
         #host: null
         #profile: 'default'
         user_entity_guesser: 'Webauthn\Bundle\Security\Guesser\CurrentUserEntityGuesser'
diff --git a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php
index d2ed391..ef91ea5 100644
--- a/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php
+++ b/tests/symfony/functional/Attestation/AdditionalAuthenticatorTest.php
@@ -62,10 +62,11 @@ final class AdditionalAuthenticatorTest extends WebTestCase
         ];
         foreach ($expectedKeys as $expectedKey) {
             static::assertArrayHasKey($expectedKey, $data);
         }
         static::assertSame('ok', $data['status']);
+        static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled
     }
 
     #[Test]
     public function thePublicKeyCredentialDataCanBeLoaded(): void
     {
diff --git a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php
index ecb5376..570d007 100644
--- a/tests/symfony/functional/PublicKeyCredentialSourceRepository.php
+++ b/tests/symfony/functional/PublicKeyCredentialSourceRepository.php
@@ -36,10 +36,28 @@ final class PublicKeyCredentialSourceRepository implements PublicKeyCredentialSo
             ),
             'foo',
             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
     {
         $item = $this->cacheItemPool->getItem('pks-' . Base64UrlSafe::encodeUnpadded($publicKeyCredentialId));

Vulnerable code: 		-
			message: "#^Should not use function \"dump\", please change the code\\.$#"
			count: 1
			path: src/symfony/src/CredentialOptionsBuilder/ProfileBasedRequestOptionsBuilder.php

            $failureHandler
            $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest($request, $userEntity);
        PublicKeyCredentialUserEntity $userEntity
        $excludedCredentials = $this->getCredentials($userEntity);
        PublicKeyCredentialUserEntity $userEntity
Fixed code: 		-
			message: "#^Method Webauthn\\\\Bundle\\\\CredentialOptionsBuilder\\\\PublicKeyCredentialCreationOptionsBuilder\\:\\:getFromRequest\\(\\) invoked with 3 parameters, 2 required\\.$#"
			count: 1
			path: src/symfony/src/Controller/AttestationRequestController.php

		-
			message: "#^Cannot access offset 'hide_existing…' on mixed\\.$#"
			count: 1
			path: src/symfony/src/DependencyInjection/WebauthnExtension.php

        bool $hideExistingExcludedCredentials = false
            $failureHandler,
            $hideExistingExcludedCredentials
        private readonly bool $hideExistingExcludedCredentials = false,
            $publicKeyCredentialCreationOptions = $this->extractor->getFromRequest(
                $request,
                $userEntity,
                $this->hideExistingExcludedCredentials
            );
        PublicKeyCredentialUserEntity $userEntity,
        bool $hideExistingExcludedCredentials = false
        $excludedCredentials = $hideExistingExcludedCredentials === true ? [] : $this->getCredentials($userEntity);
        PublicKeyCredentialUserEntity $userEntity,
        /*bool $hideExistingExcludedCredentials = false*/
            ->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()
                true,
                    $creationConfig['hide_existing_credentials'] ?? false,
        hide_existing_credentials: true
        static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled
        $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);