From f9d265ffae69664e4190cb5bc29d3228e6eafc3d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 18 Mar 2026 10:12:12 +0000 Subject: [PATCH 1/3] Harden `URL::isExternalToApplication()` --- src/Facades/Endpoint/URL.php | 32 ++++++++++++++++++- .../Facades/Concerns/ProvidesExternalUrls.php | 18 +++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index cd969d10b3..70810c8445 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -274,7 +274,9 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => $urlDomain === $siteUrl) ->isEmpty(); - $isExternalToCurrentRequestDomain = ! Str::startsWith($url, self::getDomainFromAbsolute(url()->to('/'))); + $urlHost = self::getHostFromUrl($urlWithoutQuery); + $currentRequestHost = self::getHostFromUrl(url()->to('/')); + $isExternalToCurrentRequestDomain = $urlHost !== $currentRequestHost; return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } @@ -389,6 +391,34 @@ private function getDomainFromAbsolute(string $url): string return preg_replace('/(https*:\/\/[^\/]+)(.*)/', '$1', $url); } + /** + * Safely extract the host from a URL using parse_url. + * + * This properly handles URL credential injection attacks like + * "http://trusted.com@evil.com/path" where the actual host is "evil.com". + */ + private function getHostFromUrl(?string $url): ?string + { + if (! $url) { + return null; + } + + $parsed = parse_url($url); + + if (! isset($parsed['host'])) { + return null; + } + + $host = strtolower($parsed['host']); + + // Include the port in the comparison to match the original behavior + if (isset($parsed['port'])) { + $host .= ':'.$parsed['port']; + } + + return $host; + } + /** * Get the current root URL from request headers. */ diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 867aaf1540..f6bbb49eb1 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -67,6 +67,24 @@ public static function externalUrlProvider() ['http://subdomain.this-site.com.au/some-slug', true], ['http://subdomain.this-site.com.au/some-slug?foo', true], ['http://subdomain.this-site.com.au/some-slug#anchor', true], + + // URL credential injection attacks + // These use the userinfo@host syntax to trick naive URL parsing + ['http://this-site.com@evil.com', true], + ['http://this-site.com@evil.com/', true], + ['http://this-site.com@evil.com/path', true], + ['http://this-site.com@evil.com/path?query', true], + ['http://this-site.com:password@evil.com', true], + ['http://user:pass@evil.com', true], + ['http://absolute-url-resolved-from-request.com@evil.com', true], + ['http://absolute-url-resolved-from-request.com@evil.com/path', true], + ['http://subdomain.this-site.com@evil.com', true], + ['http://subdomain.this-site.com@evil.com/path', true], + + // URL credential injection with port numbers + ['http://this-site.com:8000@evil.com', true], + ['http://this-site.com:8000@evil.com/path', true], + ['http://this-site.com:8000@webhook.site/token', true], ]; } } From db4b16d51902ef0f3e205262a4bca71a13c9366c Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 12:02:24 -0400 Subject: [PATCH 2/3] Use parse_url in getDomainFromAbsolute to fix credential injection Replace brittle regex with parse_url(PHP_URL_HOST) to correctly handle userinfo@host URL injection attacks. Remove the now-unnecessary getHostFromUrl method. Co-Authored-By: Claude Opus 4.6 --- src/Facades/Endpoint/URL.php | 34 ++-------------------------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 70810c8445..7c6077f178 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -274,9 +274,7 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => $urlDomain === $siteUrl) ->isEmpty(); - $urlHost = self::getHostFromUrl($urlWithoutQuery); - $currentRequestHost = self::getHostFromUrl(url()->to('/')); - $isExternalToCurrentRequestDomain = $urlHost !== $currentRequestHost; + $isExternalToCurrentRequestDomain = $urlDomain !== self::getDomainFromAbsolute(url()->to('/')); return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; } @@ -388,35 +386,7 @@ private function getAbsoluteSiteUrls(): Collection */ private function getDomainFromAbsolute(string $url): string { - return preg_replace('/(https*:\/\/[^\/]+)(.*)/', '$1', $url); - } - - /** - * Safely extract the host from a URL using parse_url. - * - * This properly handles URL credential injection attacks like - * "http://trusted.com@evil.com/path" where the actual host is "evil.com". - */ - private function getHostFromUrl(?string $url): ?string - { - if (! $url) { - return null; - } - - $parsed = parse_url($url); - - if (! isset($parsed['host'])) { - return null; - } - - $host = strtolower($parsed['host']); - - // Include the port in the comparison to match the original behavior - if (isset($parsed['port'])) { - $host .= ':'.$parsed['port']; - } - - return $host; + return parse_url($url, PHP_URL_HOST) ?? $url; } /** From 1778b814c637c27396e1569aa2b3009e764d88e7 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Wed, 18 Mar 2026 12:07:49 -0400 Subject: [PATCH 3/3] Clean up credential injection test data provider comments Co-Authored-By: Claude Opus 4.6 --- tests/Facades/Concerns/ProvidesExternalUrls.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index f6bbb49eb1..731b22d637 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -68,8 +68,7 @@ public static function externalUrlProvider() ['http://subdomain.this-site.com.au/some-slug?foo', true], ['http://subdomain.this-site.com.au/some-slug#anchor', true], - // URL credential injection attacks - // These use the userinfo@host syntax to trick naive URL parsing + // Credential injection ['http://this-site.com@evil.com', true], ['http://this-site.com@evil.com/', true], ['http://this-site.com@evil.com/path', true], @@ -80,8 +79,6 @@ public static function externalUrlProvider() ['http://absolute-url-resolved-from-request.com@evil.com/path', true], ['http://subdomain.this-site.com@evil.com', true], ['http://subdomain.this-site.com@evil.com/path', true], - - // URL credential injection with port numbers ['http://this-site.com:8000@evil.com', true], ['http://this-site.com:8000@evil.com/path', true], ['http://this-site.com:8000@webhook.site/token', true],