Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 77 additions & 12 deletions src/Facades/Endpoint/URL.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand All @@ -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.
*/
Expand Down Expand Up @@ -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('#');
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions tests/Facades/Concerns/ProvidesExternalUrls.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>',
'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",
];
}

Expand Down
15 changes: 14 additions & 1 deletion tests/Facades/UrlTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading