From 4e7d4abc0e5e2df0812d226fd0ac3a77aa178574 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 19 Mar 2026 16:40:50 +0000 Subject: [PATCH 1/6] add test cases --- .../Facades/Concerns/ProvidesExternalUrls.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 633a7c4c49..f3b082c229 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -88,6 +88,36 @@ private static function externalUrls() 'http://this-site.com:8000@evil.com', 'http://this-site.com:8000@evil.com/path', 'http://this-site.com:8000@webhook.site/token', + + // Backslash bypass + 'http://evil.com\@this-site.com', + 'http://evil.com\@this-site.com/', + 'http://evil.com\@this-site.com/path', + 'http://evil.com\@subdomain.this-site.com', + 'http://evil.com\@absolute-url-resolved-from-request.com', + 'https://evil.com\@this-site.com', + 'http://evil.com\\@this-site.com', + 'http://evil.com\\\@this-site.com', + + // Dangerous URL schemes + 'javascript:alert(1)', + 'javascript:alert(document.cookie)', + 'javascript://this-site.com/%0aalert(1)', + 'JAVASCRIPT:alert(1)', + 'data:text/html,', + 'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==', + 'DATA:text/html,test', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + + // Whitespace bypass + ' http://this-site.com', + ' http://evil.com', + ' http://evil.com', + "\thttp://evil.com", + "\nhttp://evil.com", + "\rhttp://evil.com", + "\r\nhttp://evil.com", ]; } From 394f722e4fa55ee12cdaa5577ccc08bb4cd8ca8c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 19 Mar 2026 16:40:55 +0000 Subject: [PATCH 2/6] fix --- src/Facades/Endpoint/URL.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 7c6077f178..4862650bc8 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -257,10 +257,15 @@ public function isExternalToApplication(?string $url): bool return false; } - if (Str::startsWith($url, '//')) { + if (! Str::startsWith($url, ['/', 'http://', 'https://', '#', '?']) || Str::startsWith($url, '//')) { return self::$externalAppUrlsCache[$url] = true; } + // Normalize backslashes to forward slashes. + // Browsers treat \ as / for special schemes (http/https), which can + // cause parse_url() to extract a different host than the browser uses. + $url = str_replace('\\', '/', $url); + $url = Str::ensureRight($url, '/'); if (Str::startsWith($url, ['/', '?', '#'])) { From 760495add654fab0f1318af762cba5fbb4adb4e6 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 17:01:30 -0400 Subject: [PATCH 3/6] bring over tests from the 5.x pr and make necessary changes to support it --- src/Facades/Endpoint/URL.php | 36 +++++++++++++++++-- .../Facades/Concerns/ProvidesExternalUrls.php | 16 +++++++++ tests/Facades/UrlTest.php | 13 +++++++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 4862650bc8..e6ff8f329e 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -43,7 +43,7 @@ public function tidy(?string $url, ?bool $external = false, ?bool $withTrailingS $url = Path::tidy($url); // If URL is external to this Statamic application, we'll leave leading/trailing slashes by default. - if (! $external && self::isAbsolute($url) && self::isExternalToApplication($url)) { + if (! $external && $this->shouldLeaveExternalAbsoluteUrlUntouched($url)) { return $url; } @@ -106,7 +106,7 @@ public function replaceSlug(?string $url, string $slug): string */ public function parent(?string $url): string { - $trailingSlash = self::isAbsolute($url) && self::isExternalToApplication($url) + $trailingSlash = $this->shouldLeaveExternalAbsoluteUrlUntouched($url) ? self::hasTrailingSlash($url) : self::$enforceTrailingSlashes; @@ -190,7 +190,7 @@ public function makeRelative(?string $url): string public function makeAbsolute(?string $url): string { // If URL is external to this Statamic application, we'll just leave it as-is. - if (self::isAbsolute($url) && self::isExternalToApplication($url)) { + if ($this->shouldLeaveExternalAbsoluteUrlUntouched($url)) { return $url; } @@ -212,6 +212,27 @@ public function getCurrent(): string return self::tidy(request()->path()); } + private function shouldLeaveExternalAbsoluteUrlUntouched(?string $url): bool + { + if (! self::isAbsolute($url) || ! self::isExternalToApplication($url)) { + return false; + } + + return ! $this->hostMatchesConfiguredAppUrl($url); + } + + private function hostMatchesConfiguredAppUrl(?string $url): bool + { + if (! $url || ! self::isAbsolute($url)) { + return false; + } + + $host = parse_url($url, PHP_URL_HOST); + $appHost = parse_url((string) config('app.url'), PHP_URL_HOST); + + return $host && $appHost && strtolower($host) === strtolower($appHost); + } + /** * Check whether a URL is absolute. */ @@ -265,6 +286,7 @@ public function isExternalToApplication(?string $url): bool // Browsers treat \ as / for special schemes (http/https), which can // cause parse_url() to extract a different host than the browser uses. $url = str_replace('\\', '/', $url); + $url = preg_replace('/%5c/i', '/', $url); $url = Str::ensureRight($url, '/'); @@ -279,6 +301,14 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => $urlDomain === $siteUrl) ->isEmpty(); + $hasRelativeSite = Site::all()->contains( + fn ($site) => Str::startsWith((string) ($site->rawConfig()['url'] ?? ''), '/') + ); + + if (! $hasRelativeSite) { + return self::$externalAppUrlsCache[$url] = $isExternalToSites; + } + $isExternalToCurrentRequestDomain = $urlDomain !== self::getDomainFromAbsolute(url()->to('/')); return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index f3b082c229..4c0745c24d 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -99,6 +99,22 @@ private static function externalUrls() 'http://evil.com\\@this-site.com', 'http://evil.com\\\@this-site.com', + // Percent-encoded backslash bypass + 'http://evil.com%5c@this-site.com', + 'http://evil.com%5c@this-site.com/', + 'http://evil.com%5c@this-site.com/path', + 'http://evil.com%5c@subdomain.this-site.com', + 'http://evil.com%5c@absolute-url-resolved-from-request.com', + 'https://evil.com%5C@this-site.com', + + // Absolute-looking URL with no host (parse_url() returns false) + 'http:///path', + + // Percent-encoded whitespace bypass + '%20http://evil.com', + '%09http://evil.com', + '%0ahttp://evil.com', + // Dangerous URL schemes 'javascript:alert(1)', 'javascript:alert(document.cookie)', diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 93604b5856..06bd67dd3d 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -252,11 +252,24 @@ public function it_determines_if_external_url_to_application_when_only_current_r { $this->setSites([ 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'b' => ['name' => 'B', 'locale' => 'en_GB', 'url' => '/'], ]); $this->assertFalse(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/some-slug')); } + #[Test] + public function it_does_not_trust_current_request_domain_when_no_sites_are_relative() + { + $this->setSites([ + 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], + 'b' => ['name' => 'B', 'locale' => 'en_US', 'url' => 'http://subdomain.this-site.com/'], + ]); + + $this->assertTrue(URL::isExternalToApplication('http://absolute-url-resolved-from-request.com/')); + $this->assertFalse(URL::isExternalToApplication('http://this-site.com/')); + } + #[Test] #[DataProvider('assembleProvider')] public function it_can_assemble_urls($segments, $assembled) From 1abe22e68d1b288fe033d4bd83d88ab5890df2d1 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 17:07:59 -0400 Subject: [PATCH 4/6] add property cache for relative site lookup --- src/Facades/Endpoint/URL.php | 48 ++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index e6ff8f329e..d4f8b22e33 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -15,6 +15,8 @@ class URL { private static $enforceTrailingSlashes = false; private static $absoluteSiteUrlsCache; + private static $hasRelativeSiteCache; + private static $siteCachesLoaded = false; private static $externalSiteUrlsCache; private static $externalAppUrlsCache; @@ -301,11 +303,7 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => $urlDomain === $siteUrl) ->isEmpty(); - $hasRelativeSite = Site::all()->contains( - fn ($site) => Str::startsWith((string) ($site->rawConfig()['url'] ?? ''), '/') - ); - - if (! $hasRelativeSite) { + if (! $this->hasRelativeSite()) { return self::$externalAppUrlsCache[$url] = $isExternalToSites; } @@ -369,7 +367,9 @@ public function removeQueryAndFragment(?string $url): ?string */ public function clearUrlCache(): void { + self::$siteCachesLoaded = false; self::$absoluteSiteUrlsCache = null; + self::$hasRelativeSiteCache = null; self::$externalSiteUrlsCache = null; self::$externalAppUrlsCache = null; } @@ -402,18 +402,46 @@ private function normalizeTrailingSlash(?string $url, ?bool $withTrailingSlash = } /** - * Get and cache absolute site URLs for external checks. + * Warm site-derived caches from a single Site::all() call */ - private function getAbsoluteSiteUrls(): Collection + private function ensureSiteCaches(): void { - if (self::$absoluteSiteUrlsCache) { - return self::$absoluteSiteUrlsCache; + if (self::$siteCachesLoaded) { + return; } - return self::$absoluteSiteUrlsCache = Site::all() + $sites = Site::all(); + + self::$hasRelativeSiteCache = $sites->contains( + fn ($site) => Str::startsWith((string) ($site->rawConfig()['url'] ?? ''), '/') + ); + + self::$absoluteSiteUrlsCache = $sites ->map(fn ($site) => $site->rawConfig()['url'] ?? null) ->filter(fn ($siteUrl) => self::isAbsolute($siteUrl)) ->map(fn ($siteUrl) => self::getDomainFromAbsolute($siteUrl)); + + self::$siteCachesLoaded = true; + } + + /** + * Get and cache absolute site URLs for external checks. + */ + private function getAbsoluteSiteUrls(): Collection + { + $this->ensureSiteCaches(); + + return self::$absoluteSiteUrlsCache; + } + + /** + * Checks whether there is a site configured with a relative URL. + */ + private function hasRelativeSite(): bool + { + $this->ensureSiteCaches(); + + return self::$hasRelativeSiteCache; } /** From 000354c663a1b8cccc97a15fc8f1633dc4f6ee64 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 17:21:02 -0400 Subject: [PATCH 5/6] clarify in test name --- tests/Facades/UrlTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 06bd67dd3d..64cb4cf993 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -248,7 +248,7 @@ public function it_determines_if_external_url_to_application($url, $expected) } #[Test] - public function it_determines_if_external_url_to_application_when_only_current_request_domain_matches() + public function it_determines_if_external_url_to_application_when_only_current_request_domain_matches_when_theres_a_relative_site_url() { $this->setSites([ 'a' => ['name' => 'A', 'locale' => 'en_US', 'url' => 'http://this-site.com/'], From 0698ee3d004fb0a0659ebea1ae079962a98c6272 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 20 Mar 2026 17:49:00 -0400 Subject: [PATCH 6/6] use the original url as the cache key - not the normalized url. --- src/Facades/Endpoint/URL.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index d4f8b22e33..e3acb20a73 100644 --- a/src/Facades/Endpoint/URL.php +++ b/src/Facades/Endpoint/URL.php @@ -280,8 +280,10 @@ public function isExternalToApplication(?string $url): bool return false; } + $cacheKey = $url; + if (! Str::startsWith($url, ['/', 'http://', 'https://', '#', '?']) || Str::startsWith($url, '//')) { - return self::$externalAppUrlsCache[$url] = true; + return self::$externalAppUrlsCache[$cacheKey] = true; } // Normalize backslashes to forward slashes. @@ -293,7 +295,7 @@ public function isExternalToApplication(?string $url): bool $url = Str::ensureRight($url, '/'); if (Str::startsWith($url, ['/', '?', '#'])) { - return self::$externalAppUrlsCache[$url] = false; + return self::$externalAppUrlsCache[$cacheKey] = false; } $urlWithoutQuery = Str::of($url)->before('?')->before('#'); @@ -304,12 +306,12 @@ public function isExternalToApplication(?string $url): bool ->isEmpty(); if (! $this->hasRelativeSite()) { - return self::$externalAppUrlsCache[$url] = $isExternalToSites; + return self::$externalAppUrlsCache[$cacheKey] = $isExternalToSites; } $isExternalToCurrentRequestDomain = $urlDomain !== self::getDomainFromAbsolute(url()->to('/')); - return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; + return self::$externalAppUrlsCache[$cacheKey] = $isExternalToSites && $isExternalToCurrentRequestDomain; } /**