CVE-2024-39912 (published 2024-07-15T20:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/web-auth/webauthn-framework
commit_details: repo_url: https://github.com/web-auth/webauthn-framework
author: Florent Morselli
author_date: 2024-07-06 06:53:56+02:00
msg: 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.
repository: repo_url: https://github.com/web-auth/webauthn-framework
repo_name: web-auth/webauthn-framework
description: FIDO-U2F / FIDO2 / Webauthn Framework
repo_language: PHP
owner: web-auth
file_changes: filename: phpstan-baseline.neon
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: PHP
filename: AttestationControllerFactory.php
change_type: ModificationType.MODIFY
diff: @@ -72,13 +72,15 @@ final class AttestationControllerFactory
         OptionsStorage $optionStorage,
         CreationOptionsHandler $creationOptionsHandler,
         FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
+        bool $hideExistingExcludedCredentials = false
     ): AttestationRequestController {
         return new AttestationRequestController(
             $optionsBuilder,
             $userEntityGuesser,
             $optionStorage,
             $creationOptionsHandler,
-            $failureHandler
+            $failureHandler,
+            $hideExistingExcludedCredentials
         );
     }
 

programming_language: PHP
filename: AttestationRequestController.php
change_type: ModificationType.MODIFY
diff: @@ -24,6 +24,7 @@ final class AttestationRequestController
         private readonly OptionsStorage $optionsStorage,
         private readonly CreationOptionsHandler $creationOptionsHandler,
         private readonly FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
+        private readonly bool $hideExistingExcludedCredentials = false,
     ) {
     }
 
@@ -31,7 +32,11 @@ final class AttestationRequestController
     {
         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,

programming_language: PHP
filename: ProfileBasedCreationOptionsBuilder.php
change_type: ModificationType.MODIFY
diff: @@ -48,7 +48,8 @@ final class ProfileBasedCreationOptionsBuilder implements PublicKeyCredentialCre
 
     public function getFromRequest(
         Request $request,
-        PublicKeyCredentialUserEntity $userEntity
+        PublicKeyCredentialUserEntity $userEntity,
+        bool $hideExistingExcludedCredentials = false
     ): PublicKeyCredentialCreationOptions {
         $format = method_exists(
             $request,
@@ -57,7 +58,7 @@ final class ProfileBasedCreationOptionsBuilder implements PublicKeyCredentialCre
         $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;

programming_language: PHP
filename: PublicKeyCredentialCreationOptionsBuilder.php
change_type: ModificationType.MODIFY
diff: @@ -12,6 +12,7 @@ interface PublicKeyCredentialCreationOptionsBuilder
 {
     public function getFromRequest(
         Request $request,
-        PublicKeyCredentialUserEntity $userEntity
+        PublicKeyCredentialUserEntity $userEntity,
+        /*bool $hideExistingExcludedCredentials = false*/
     ): PublicKeyCredentialCreationOptions;
 }

programming_language: PHP
filename: Configuration.php
change_type: ModificationType.MODIFY
diff: @@ -354,6 +354,12 @@ final class Configuration implements ConfigurationInterface
             ->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')

programming_language: PHP
filename: WebauthnFactory.php
change_type: ModificationType.MODIFY
diff: @@ -415,6 +415,7 @@ final class WebauthnFactory implements FirewallListenerFactoryInterface, Authent
                 new Reference($optionsStorageId),
                 new Reference($optionsHandlerId),
                 new Reference($failureHandlerId),
+                true,
             ]);
         $this->createControllerAndRoute(
             $container,

programming_language: PHP
filename: WebauthnExtension.php
change_type: ModificationType.MODIFY
diff: @@ -215,6 +215,7 @@ final class WebauthnExtension extends Extension implements PrependExtensionInter
                     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'],

programming_language: PHP
filename: config.yml
change_type: ModificationType.MODIFY
diff: @@ -131,6 +131,7 @@ webauthn:
     enabled: true
     creation:
       test:
+        hide_existing_credentials: true
         options_path: '/devices/add/options'
         result_path: '/devices/add'
         #host: null

programming_language: CoffeeScript
filename: AdditionalAuthenticatorTest.php
change_type: ModificationType.MODIFY
diff: @@ -64,6 +64,7 @@ final class AdditionalAuthenticatorTest extends WebTestCase
             static::assertArrayHasKey($expectedKey, $data);
         }
         static::assertSame('ok', $data['status']);
+        static::assertArrayNotHasKey('excludeCredentials', $data); // username enumeration prevention is enabled
     }
 
     #[Test]

programming_language: PHP
filename: PublicKeyCredentialSourceRepository.php
change_type: ModificationType.MODIFY
diff: @@ -38,6 +38,24 @@ final class PublicKeyCredentialSourceRepository implements PublicKeyCredentialSo
             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

programming_language: PHP