diff --git a/src/Facades/Endpoint/URL.php b/src/Facades/Endpoint/URL.php index 7c6077f178..e3acb20a73 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; @@ -43,7 +45,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 +108,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 +192,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 +214,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. */ @@ -257,14 +280,22 @@ public function isExternalToApplication(?string $url): bool return false; } - if (Str::startsWith($url, '//')) { - return self::$externalAppUrlsCache[$url] = true; + $cacheKey = $url; + + if (! Str::startsWith($url, ['/', 'http://', 'https://', '#', '?']) || Str::startsWith($url, '//')) { + return self::$externalAppUrlsCache[$cacheKey] = 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 = preg_replace('/%5c/i', '/', $url); + $url = Str::ensureRight($url, '/'); if (Str::startsWith($url, ['/', '?', '#'])) { - return self::$externalAppUrlsCache[$url] = false; + return self::$externalAppUrlsCache[$cacheKey] = false; } $urlWithoutQuery = Str::of($url)->before('?')->before('#'); @@ -274,9 +305,13 @@ public function isExternalToApplication(?string $url): bool ->filter(fn ($siteUrl) => $urlDomain === $siteUrl) ->isEmpty(); + if (! $this->hasRelativeSite()) { + return self::$externalAppUrlsCache[$cacheKey] = $isExternalToSites; + } + $isExternalToCurrentRequestDomain = $urlDomain !== self::getDomainFromAbsolute(url()->to('/')); - return self::$externalAppUrlsCache[$url] = $isExternalToSites && $isExternalToCurrentRequestDomain; + return self::$externalAppUrlsCache[$cacheKey] = $isExternalToSites && $isExternalToCurrentRequestDomain; } /** @@ -334,7 +369,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; } @@ -367,18 +404,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; } /** diff --git a/tests/Facades/Concerns/ProvidesExternalUrls.php b/tests/Facades/Concerns/ProvidesExternalUrls.php index 633a7c4c49..4c0745c24d 100644 --- a/tests/Facades/Concerns/ProvidesExternalUrls.php +++ b/tests/Facades/Concerns/ProvidesExternalUrls.php @@ -88,6 +88,52 @@ 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', + + // 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)', + '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", ]; } diff --git a/tests/Facades/UrlTest.php b/tests/Facades/UrlTest.php index 93604b5856..64cb4cf993 100644 --- a/tests/Facades/UrlTest.php +++ b/tests/Facades/UrlTest.php @@ -248,15 +248,28 @@ 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/'], + '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)