VYPR
Medium severity5.4NVD Advisory· Published Mar 10, 2026· Updated May 7, 2026

CVE-2026-30964

CVE-2026-30964

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. Prior to 5.2.4, when allowed_origins is configured, CheckAllowedOrigins reduces URL-like values to their host component and accepts on host match alone. This makes exact origin policies impossible to express: scheme and port differences are silently ignored. This vulnerability is fixed in 5.2.4.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
web-auth/webauthn-frameworkPackagist
>= 5.2.0, < 5.2.45.2.4
web-auth/webauthn-libPackagist
>= 5.2.0, < 5.2.45.2.4
web-auth/webauthn-symfony-bundlePackagist
>= 5.2.0, < 5.2.45.2.4

Affected products

5

Patches

2
535cc3c2dcbd

fix: merge up CVE fix from 5.2.x - full origin validation in CheckAllowedOrigins

https://github.com/web-auth/webauthn-frameworkFlorent MorselliMar 8, 2026via ghsa
1 file changed · +112 31
  • src/webauthn/src/CeremonyStep/CheckAllowedOrigins.php+112 31 modified
    @@ -23,9 +23,18 @@
     final readonly class CheckAllowedOrigins implements CeremonyStep
     {
         /**
    +     * Full origin entries (scheme://host[:port]) from allowed origins that include a scheme.
    +     *
          * @var string[]
          */
    -    private array $allowedOrigins;
    +    private array $fullOrigins;
    +
    +    /**
    +     * Host-only entries from allowed origins without a scheme (backward compatibility).
    +     *
    +     * @var string[]
    +     */
    +    private array $hostOrigins;
     
         /**
          * @param string[] $allowedOrigins
    @@ -34,15 +43,20 @@ public function __construct(
             array $allowedOrigins,
             private bool $allowSubdomains = false
         ) {
    +        $fullOrigins = [];
    +        $hostOrigins = [];
             foreach ($allowedOrigins as $allowedOrigin) {
    -            $parsedAllowedOrigin = parse_url($allowedOrigin);
    -            $parsedAllowedOrigin !== false || throw new InvalidArgumentException(sprintf(
    -                'Invalid origin: %s',
    -                $allowedOrigin
    -            ));
    +            $parsed = parse_url($allowedOrigin);
    +            $parsed !== false || throw new InvalidArgumentException(sprintf('Invalid origin: %s', $allowedOrigin));
    +            if (isset($parsed['scheme'], $parsed['host'])) {
    +                $fullOrigins[] = self::buildOrigin($parsed['scheme'], $parsed['host'], $parsed['port'] ?? null);
    +            } else {
    +                $hostOrigins[] = $parsed['host'] ?? $allowedOrigin;
    +            }
             }
     
    -        $this->allowedOrigins = array_unique($allowedOrigins);
    +        $this->fullOrigins = array_unique($fullOrigins);
    +        $this->hostOrigins = array_unique($hostOrigins);
         }
     
         public function process(
    @@ -63,29 +77,43 @@ public function process(
             $authData = $authenticatorResponse instanceof AuthenticatorAssertionResponse ? $authenticatorResponse->authenticatorData : $authenticatorResponse->attestationObject->authData;
             $C = $authenticatorResponse->clientDataJSON;
     
    -        $parsedRelyingPartyId = parse_url($C->origin);
    -        $clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
    -        if ($clientDataRpId === '') {
    -            $clientDataRpId = $C->origin;
    -        }
    -        is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
    +        $parsedOrigin = parse_url($C->origin);
    +        is_array($parsedOrigin) || throw AuthenticatorResponseVerificationException::create(
                 'Invalid origin. Unable to parse the origin.'
             );
    -        if (in_array($C->origin, $this->allowedOrigins, true)) {
    -            return;
    -        }
    -        $allowedHosts = array_map(
    -            static fn (string $origin): string => parse_url($origin, PHP_URL_HOST) ?? $origin,
    -            $this->allowedOrigins
    -        );
    -        $isSubDomain = $this->isSubdomain($allowedHosts, $clientDataRpId);
    -        if ($this->allowSubdomains && $isSubDomain) {
    -            return;
    -        }
    -        if (! $this->allowSubdomains && $isSubDomain) {
    -            throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
    -        }
    -        if (count($this->allowedOrigins) !== 0) {
    +        $originHost = $parsedOrigin['host'] ?? $C->origin;
    +
    +        $hasAllowedOrigins = count($this->fullOrigins) !== 0 || count($this->hostOrigins) !== 0;
    +
    +        if ($hasAllowedOrigins) {
    +            // Full origin match (scheme + host + port)
    +            if (isset($parsedOrigin['scheme'], $parsedOrigin['host'])) {
    +                $normalizedOrigin = self::buildOrigin(
    +                    $parsedOrigin['scheme'],
    +                    $parsedOrigin['host'],
    +                    $parsedOrigin['port'] ?? null
    +                );
    +                if (in_array($normalizedOrigin, $this->fullOrigins, true)) {
    +                    return;
    +                }
    +            }
    +
    +            // Host-only match (backward compatibility for entries without scheme)
    +            if (in_array($originHost, $this->hostOrigins, true)) {
    +                return;
    +            }
    +
    +            // Subdomain matching
    +            $isFullOriginSubdomain = $this->isSubdomainOfFullOrigins($parsedOrigin);
    +            $isHostSubdomain = $this->isSubdomain($this->hostOrigins, $originHost);
    +            $isSubDomain = $isFullOriginSubdomain || $isHostSubdomain;
    +
    +            if ($this->allowSubdomains && $isSubDomain) {
    +                return;
    +            }
    +            if (! $this->allowSubdomains && $isSubDomain) {
    +                throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
    +            }
                 throw AuthenticatorResponseVerificationException::create(
                     'Invalid origin. Not in the list of allowed origins.'
                 );
    @@ -96,23 +124,76 @@ public function process(
             $facetId !== '' || throw AuthenticatorResponseVerificationException::create(
                 'Invalid origin. Unable to determine the facet ID.'
             );
    -        if ($clientDataRpId === $facetId) {
    +        if ($originHost === $facetId) {
                 return;
             }
    -        $isSubDomains = $this->isSubdomainOf($clientDataRpId, $facetId);
    +        $isSubDomains = $this->isSubdomainOf($originHost, $facetId);
             if ($this->allowSubdomains && $isSubDomains) {
                 return;
             }
             if (! $this->allowSubdomains && $isSubDomains) {
                 throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
             }
     
    -        $scheme = $parsedRelyingPartyId['scheme'] ?? '';
    +        $scheme = $parsedOrigin['scheme'] ?? '';
             $scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
                 'Invalid scheme. HTTPS required.'
             );
         }
     
    +    /**
    +     * @param array<string, mixed> $parsedOrigin Parsed origin from parse_url()
    +     */
    +    private function isSubdomainOfFullOrigins(array $parsedOrigin): bool
    +    {
    +        if (! isset($parsedOrigin['scheme'], $parsedOrigin['host'])) {
    +            return false;
    +        }
    +        /** @var string $originScheme */
    +        $originScheme = $parsedOrigin['scheme'];
    +        /** @var string $originHost */
    +        $originHost = $parsedOrigin['host'];
    +        $originPort = $parsedOrigin['port'] ?? null;
    +
    +        foreach ($this->fullOrigins as $fullOrigin) {
    +            $parsedAllowed = parse_url($fullOrigin);
    +            if (! is_array($parsedAllowed) || ! isset($parsedAllowed['scheme'], $parsedAllowed['host'])) {
    +                continue;
    +            }
    +            /** @var string $allowedScheme */
    +            $allowedScheme = $parsedAllowed['scheme'];
    +            /** @var string $allowedHost */
    +            $allowedHost = $parsedAllowed['host'];
    +            if ($originScheme !== $allowedScheme) {
    +                continue;
    +            }
    +            $allowedPort = $parsedAllowed['port'] ?? null;
    +            if ($originPort !== $allowedPort) {
    +                continue;
    +            }
    +            if ($this->isSubdomainOf($originHost, $allowedHost)) {
    +                return true;
    +            }
    +        }
    +
    +        return false;
    +    }
    +
    +    private static function buildOrigin(string $scheme, string $host, ?int $port): string
    +    {
    +        if ($port === null) {
    +            return sprintf('%s://%s', $scheme, $host);
    +        }
    +        $defaultPorts = [
    +            'https' => 443,
    +            'http' => 80,
    +        ];
    +        if (isset($defaultPorts[$scheme]) && $port === $defaultPorts[$scheme]) {
    +            return sprintf('%s://%s', $scheme, $host);
    +        }
    +        return sprintf('%s://%s:%d', $scheme, $host, $port);
    +    }
    +
         private function isSubdomainOf(string $subdomain, string $domain): bool
         {
             return str_ends_with('.' . $subdomain, '.' . $domain);
    
b4cd9a4394c3

Merge commit from fork

https://github.com/web-auth/webauthn-frameworkFlorent MorselliMar 8, 2026via ghsa
5 files changed · +174 33
  • src/webauthn/src/CeremonyStep/CheckAllowedOrigins.php+105 32 modified
    @@ -23,9 +23,18 @@
     final readonly class CheckAllowedOrigins implements CeremonyStep
     {
         /**
    +     * Full origin entries (scheme://host[:port]) from allowed origins that include a scheme.
    +     *
          * @var string[]
          */
    -    private array $allowedOrigins;
    +    private array $fullOrigins;
    +
    +    /**
    +     * Host-only entries from allowed origins without a scheme (backward compatibility).
    +     *
    +     * @var string[]
    +     */
    +    private array $hostOrigins;
     
         /**
          * @param string[] $allowedOrigins
    @@ -34,21 +43,20 @@ public function __construct(
             array $allowedOrigins,
             private bool $allowSubdomains = false
         ) {
    -        $origins = [];
    +        $fullOrigins = [];
    +        $hostOrigins = [];
             foreach ($allowedOrigins as $allowedOrigin) {
    -            $parsedAllowedOrigin = parse_url($allowedOrigin);
    -            $parsedAllowedOrigin !== false || throw new InvalidArgumentException(sprintf(
    -                'Invalid origin: %s',
    -                $allowedOrigin
    -            ));
    -            $allowedOriginHost = $parsedAllowedOrigin['host'] ?? '';
    -            if ($allowedOriginHost === '') {
    -                $allowedOriginHost = $allowedOrigin;
    +            $parsed = parse_url($allowedOrigin);
    +            $parsed !== false || throw new InvalidArgumentException(sprintf('Invalid origin: %s', $allowedOrigin));
    +            if (isset($parsed['scheme'], $parsed['host'])) {
    +                $fullOrigins[] = self::buildOrigin($parsed['scheme'], $parsed['host'], $parsed['port'] ?? null);
    +            } else {
    +                $hostOrigins[] = $parsed['host'] ?? $allowedOrigin;
                 }
    -            $origins[] = $allowedOriginHost;
             }
     
    -        $this->allowedOrigins = array_unique($origins);
    +        $this->fullOrigins = array_unique($fullOrigins);
    +        $this->hostOrigins = array_unique($hostOrigins);
         }
     
         public function process(
    @@ -61,25 +69,43 @@ public function process(
             $authData = $authenticatorResponse instanceof AuthenticatorAssertionResponse ? $authenticatorResponse->authenticatorData : $authenticatorResponse->attestationObject->authData;
             $C = $authenticatorResponse->clientDataJSON;
     
    -        $parsedRelyingPartyId = parse_url($C->origin);
    -        $clientDataRpId = $parsedRelyingPartyId['host'] ?? '';
    -        if ($clientDataRpId === '') {
    -            $clientDataRpId = $C->origin;
    -        }
    -        is_array($parsedRelyingPartyId) || throw AuthenticatorResponseVerificationException::create(
    +        $parsedOrigin = parse_url($C->origin);
    +        is_array($parsedOrigin) || throw AuthenticatorResponseVerificationException::create(
                 'Invalid origin. Unable to parse the origin.'
             );
    -        if (in_array($clientDataRpId, $this->allowedOrigins, true)) {
    -            return;
    -        }
    -        $isSubDomain = $this->isSubdomain($this->allowedOrigins, $clientDataRpId);
    -        if ($this->allowSubdomains && $isSubDomain) {
    -            return;
    -        }
    -        if (! $this->allowSubdomains && $isSubDomain) {
    -            throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
    -        }
    -        if (count($this->allowedOrigins) !== 0) {
    +        $originHost = $parsedOrigin['host'] ?? $C->origin;
    +
    +        $hasAllowedOrigins = count($this->fullOrigins) !== 0 || count($this->hostOrigins) !== 0;
    +
    +        if ($hasAllowedOrigins) {
    +            // Full origin match (scheme + host + port)
    +            if (isset($parsedOrigin['scheme'], $parsedOrigin['host'])) {
    +                $normalizedOrigin = self::buildOrigin(
    +                    $parsedOrigin['scheme'],
    +                    $parsedOrigin['host'],
    +                    $parsedOrigin['port'] ?? null
    +                );
    +                if (in_array($normalizedOrigin, $this->fullOrigins, true)) {
    +                    return;
    +                }
    +            }
    +
    +            // Host-only match (backward compatibility for entries without scheme)
    +            if (in_array($originHost, $this->hostOrigins, true)) {
    +                return;
    +            }
    +
    +            // Subdomain matching
    +            $isFullOriginSubdomain = $this->isSubdomainOfFullOrigins($parsedOrigin);
    +            $isHostSubdomain = $this->isSubdomain($this->hostOrigins, $originHost);
    +            $isSubDomain = $isFullOriginSubdomain || $isHostSubdomain;
    +
    +            if ($this->allowSubdomains && $isSubDomain) {
    +                return;
    +            }
    +            if (! $this->allowSubdomains && $isSubDomain) {
    +                throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
    +            }
                 throw AuthenticatorResponseVerificationException::create(
                     'Invalid origin. Not in the list of allowed origins.'
                 );
    @@ -90,23 +116,70 @@ public function process(
             $facetId !== '' || throw AuthenticatorResponseVerificationException::create(
                 'Invalid origin. Unable to determine the facet ID.'
             );
    -        if ($clientDataRpId === $facetId) {
    +        if ($originHost === $facetId) {
                 return;
             }
    -        $isSubDomains = $this->isSubdomainOf($clientDataRpId, $facetId);
    +        $isSubDomains = $this->isSubdomainOf($originHost, $facetId);
             if ($this->allowSubdomains && $isSubDomains) {
                 return;
             }
             if (! $this->allowSubdomains && $isSubDomains) {
                 throw AuthenticatorResponseVerificationException::create('Invalid origin. Subdomains are not allowed.');
             }
     
    -        $scheme = $parsedRelyingPartyId['scheme'] ?? '';
    +        $scheme = $parsedOrigin['scheme'] ?? '';
             $scheme === 'https' || throw AuthenticatorResponseVerificationException::create(
                 'Invalid scheme. HTTPS required.'
             );
         }
     
    +    /**
    +     * @param array<string, mixed> $parsedOrigin Parsed origin from parse_url()
    +     */
    +    private function isSubdomainOfFullOrigins(array $parsedOrigin): bool
    +    {
    +        if (! isset($parsedOrigin['scheme'], $parsedOrigin['host'])) {
    +            return false;
    +        }
    +        $originScheme = $parsedOrigin['scheme'];
    +        $originHost = $parsedOrigin['host'];
    +        $originPort = $parsedOrigin['port'] ?? null;
    +
    +        foreach ($this->fullOrigins as $fullOrigin) {
    +            $parsedAllowed = parse_url($fullOrigin);
    +            if (! is_array($parsedAllowed) || ! isset($parsedAllowed['scheme'], $parsedAllowed['host'])) {
    +                continue;
    +            }
    +            if ($originScheme !== $parsedAllowed['scheme']) {
    +                continue;
    +            }
    +            $allowedPort = $parsedAllowed['port'] ?? null;
    +            if ($originPort !== $allowedPort) {
    +                continue;
    +            }
    +            if ($this->isSubdomainOf($originHost, $parsedAllowed['host'])) {
    +                return true;
    +            }
    +        }
    +
    +        return false;
    +    }
    +
    +    private static function buildOrigin(string $scheme, string $host, ?int $port): string
    +    {
    +        if ($port === null) {
    +            return sprintf('%s://%s', $scheme, $host);
    +        }
    +        $defaultPorts = [
    +            'https' => 443,
    +            'http' => 80,
    +        ];
    +        if (isset($defaultPorts[$scheme]) && $port === $defaultPorts[$scheme]) {
    +            return sprintf('%s://%s', $scheme, $host);
    +        }
    +        return sprintf('%s://%s:%d', $scheme, $host, $port);
    +    }
    +
         private function isSubdomainOf(string $subdomain, string $domain): bool
         {
             return substr('.' . $subdomain, -strlen('.' . $domain)) === '.' . $domain;
    
  • tests/library/AbstractTestCase.php+1 0 modified
    @@ -104,6 +104,7 @@ protected function getCeremonyStepManagerFactory(): CeremonyStepManagerFactory
                 $this->ceremonyStepManagerFactory->setAllowedOrigins([
                     'http://localhost',
                     'https://localhost',
    +                'https://localhost:8443',
                     'https://dev.dontneeda.pw',
                     'https://spomky-webauthn.herokuapp.com',
                     'https://tuleap-web.tuleap-aio-dev.docker',
    
  • tests/library/Functional/CheckAllowedOriginsTest.php+66 0 modified
    @@ -194,6 +194,72 @@ public function emptyAllowedOriginsWithoutSubdomainsAndValidHost(): void
             static::assertTrue(true); // if no exception, test passes
         }
     
    +    #[Test]
    +    public function differentPortIsRejected(): void
    +    {
    +        // PoC from GHSA-f7pm-6hr8-7ggm: different port on same host must be rejected
    +        // C.origin = https://webauthn.spomky-labs.com (port 443)
    +        // Allowed = https://webauthn.spomky-labs.com:8443 (port 8443)
    +        $this->expectException(AuthenticatorResponseVerificationException::class);
    +        $this->expectExceptionMessage('Invalid origin');
    +
    +        $checkOrigins = new CheckAllowedOrigins(['https://webauthn.spomky-labs.com:8443']);
    +        $publicKeyCredentialSource = $this->getPublicKeyCredentialSource();
    +        $publicKeyCredentialRequestOptions = $this->getPublicKeyCredentialRequestOptions();
    +        $publicKeyCredential = $this->getPublicKeyCredential();
    +
    +        $checkOrigins->process(
    +            $publicKeyCredentialSource,
    +            $publicKeyCredential->response,
    +            $publicKeyCredentialRequestOptions,
    +            null,
    +            'webauthn.spomky-labs.com',
    +        );
    +    }
    +
    +    #[Test]
    +    public function explicitDefaultPortMatchesImplicitPort(): void
    +    {
    +        // https://webauthn.spomky-labs.com:443 should match https://webauthn.spomky-labs.com
    +        $checkOrigins = new CheckAllowedOrigins(['https://webauthn.spomky-labs.com:443']);
    +        $publicKeyCredentialSource = $this->getPublicKeyCredentialSource();
    +        $publicKeyCredentialRequestOptions = $this->getPublicKeyCredentialRequestOptions();
    +        $publicKeyCredential = $this->getPublicKeyCredential();
    +
    +        $checkOrigins->process(
    +            $publicKeyCredentialSource,
    +            $publicKeyCredential->response,
    +            $publicKeyCredentialRequestOptions,
    +            null,
    +            'webauthn.spomky-labs.com',
    +        );
    +
    +        static::assertTrue(true);
    +    }
    +
    +    #[Test]
    +    public function httpSchemeIsRejectedWhenHttpsIsConfigured(): void
    +    {
    +        // Allowed = https://webauthn.spomky-labs.com
    +        // C.origin = https://webauthn.spomky-labs.com (matches, but testing that http:// would not)
    +        // We test by configuring http:// and verifying it rejects https:// origin
    +        $this->expectException(AuthenticatorResponseVerificationException::class);
    +        $this->expectExceptionMessage('Invalid origin');
    +
    +        $checkOrigins = new CheckAllowedOrigins(['http://webauthn.spomky-labs.com']);
    +        $publicKeyCredentialSource = $this->getPublicKeyCredentialSource();
    +        $publicKeyCredentialRequestOptions = $this->getPublicKeyCredentialRequestOptions();
    +        $publicKeyCredential = $this->getPublicKeyCredential();
    +
    +        $checkOrigins->process(
    +            $publicKeyCredentialSource,
    +            $publicKeyCredential->response,
    +            $publicKeyCredentialRequestOptions,
    +            null,
    +            'webauthn.spomky-labs.com',
    +        );
    +    }
    +
         #[Test]
         public function emptyAllowedOriginsWithSubdomainsAndInvalidHost(): void
         {
    
  • tests/symfony/config/config.yml+1 0 modified
    @@ -121,6 +121,7 @@ webauthn:
       options_storage: 'Webauthn\Tests\Bundle\Functional\CustomSessionStorage'
       allowed_origins:
         - 'https://localhost'
    +    - 'https://localhost:8443'
         - 'https://bar.acme'
         - 'https://webauthn.spomky-labs.com'
         - 'https://spomky-webauthn.herokuapp.com'
    
  • tests/symfony/functional/Firewall/AllowedOriginsTest.php+1 1 modified
    @@ -27,7 +27,7 @@ public function allowedOriginsAreAvailable(): void
             //Then
             static::assertResponseIsSuccessful();
             static::assertSame(
    -            '{"origins":["https:\/\/localhost","https:\/\/bar.acme","https:\/\/webauthn.spomky-labs.com","https:\/\/spomky-webauthn.herokuapp.com"]}',
    +            '{"origins":["https:\/\/localhost","https:\/\/localhost:8443","https:\/\/bar.acme","https:\/\/webauthn.spomky-labs.com","https:\/\/spomky-webauthn.herokuapp.com"]}',
                 $client->getResponse()
                     ->getContent()
             );
    

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

5

News mentions

0

No linked articles in our index yet.