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.
| Package | Affected versions | Patched versions |
|---|---|---|
web-auth/webauthn-frameworkPackagist | >= 5.2.0, < 5.2.4 | 5.2.4 |
web-auth/webauthn-libPackagist | >= 5.2.0, < 5.2.4 | 5.2.4 |
web-auth/webauthn-symfony-bundlePackagist | >= 5.2.0, < 5.2.4 | 5.2.4 |
Affected products
5- cpe:2.3:a:spomky-labs:webauthn_framwork:*:*:*:*:*:*:*:*Range: >=5.2.0,<5.2.4
- cpe:2.3:a:spomky-labs:webauthn-symfony-bundle:*:*:*:*:*:*:*:*Range: >=5.2.0,<5.2.4
- Range: < 5.2.4
- web-auth/webauthn-symfony-bundlev5Range: < 5.2.4
Patches
2535cc3c2dcbdfix: merge up CVE fix from 5.2.x - full origin validation in CheckAllowedOrigins
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);
b4cd9a4394c3Merge commit from fork
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- github.com/web-auth/webauthn-framework/commit/535cc3c2dcbd9c3dfd5e00a254ad4a984e5e7839nvdPatchWEB
- github.com/web-auth/webauthn-framework/commit/b4cd9a4394c35fcac6080fd2f84f4f58a30abc01nvdPatchWEB
- github.com/web-auth/webauthn-framework/security/advisories/GHSA-f7pm-6hr8-7ggmnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-f7pm-6hr8-7ggmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-30964ghsaADVISORY
News mentions
0No linked articles in our index yet.