From e1542274e867f919b001b34b4993a48805060634 Mon Sep 17 00:00:00 2001 From: Nafis Ahmed Date: Thu, 23 Oct 2025 16:22:43 +0600 Subject: [PATCH 1/2] feat: add securityCheck parameter to previousPath method for optional security check --- src/Illuminate/Routing/UrlGenerator.php | 101 +++++++++++++++++++++++- 1 file changed, 97 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Routing/UrlGenerator.php b/src/Illuminate/Routing/UrlGenerator.php index ee1e178760a9..8b3e692958f5 100755 --- a/src/Illuminate/Routing/UrlGenerator.php +++ b/src/Illuminate/Routing/UrlGenerator.php @@ -175,16 +175,109 @@ public function previous($fallback = false) } /** - * Get the previous path info for the request. + * Get the previous path for the request. * * @param mixed $fallback + * @param bool $securityCheck Whether to check for potential security vulnerabilities * @return string */ - public function previousPath($fallback = false) + public function previousPath($fallback = false, $securityCheck = false) { - $previousPath = str_replace($this->to('/'), '', rtrim(preg_replace('/\?.*/', '', $this->previous($fallback)), '/')); + if (! $securityCheck) { + $previousPath = str_replace($this->to('/'), '', rtrim(preg_replace('/\?.*/', '', $this->previous($fallback)), '/')); - return $previousPath === '' ? '/' : $previousPath; + return $previousPath === '' ? '/' : $previousPath; + } + + $referrer = $this->request->headers->get('referer'); + + if (! $referrer) { + $referrer = $this->getPreviousUrlFromSession(); + } + + if (! $referrer) { + return $this->getPathFromUrl($fallback); + } + + return $this->getSecurePreviousPath($referrer, $fallback); + } + + /** + * Get the secure previous path with security checks. + * + * @param string $referrer + * @param mixed $fallback + * @return string + */ + protected function getSecurePreviousPath($referrer, $fallback = false): string + { + if ($this->isDangerousUrl($referrer)) { + return $this->getPathFromUrl($fallback); + } + + $previous = $this->to($referrer); + $previousUrlComponents = parse_url($previous); + $appUrlComponents = parse_url($this->to('/')); + + if (! $this->isSameOrigin($previousUrlComponents, $appUrlComponents)) { + $previous = $fallback ? $this->to($fallback) : $this->to('/'); + } + + return $this->getPathFromUrl($previous); + } + + /** + * Check for dangerous schemes like javascript, data, or file. + * + * @param string $url + * @return bool + */ + protected function isDangerousUrl($url) + { + // will return true if the URL starts with javascript, data, or file + return preg_match('/^(javascript|data|file):/i', $url); + } + + /** + * Determine if two URLs are from the same origin, matching host, scheme, and port. + * + * @param array|false $urlOneComponents + * @param array|false $urlTwoComponents + * @return bool + */ + protected function isSameOrigin($urlOneComponents, $urlTwoComponents) + { + if ($urlOneComponents === false || $urlTwoComponents === false) { + return false; + } + + $hostOne = $urlOneComponents['host'] ?? null; + $hostTwo = $urlTwoComponents['host'] ?? null; + $schemeOne = $urlOneComponents['scheme'] ?? null; + $schemeTwo = $urlTwoComponents['scheme'] ?? null; + $portOne = $urlOneComponents['port'] ?? null; + $portTwo = $urlTwoComponents['port'] ?? null; + + if (! $hostOne || ! $hostTwo || ! $schemeOne || ! $schemeTwo) { + return false; + } + + return strtolower($hostOne) === strtolower($hostTwo) && + $schemeOne === $schemeTwo && + $portOne === $portTwo; + } + + /** + * Extract path from a URL. + * + * @param mixed $url + * @return string + */ + protected function getPathFromUrl($url) + { + $path = $url ? parse_url($this->to($url), PHP_URL_PATH) ?? '/' : '/'; + + return rtrim($path, '/') ?: '/'; } /** From 6e3399aa00c8938cf0e4af230e851f66893d81c8 Mon Sep 17 00:00:00 2001 From: Nafis Ahmed Date: Thu, 23 Oct 2025 16:23:10 +0600 Subject: [PATCH 2/2] test: add tests for both backward compatibility (old behavior) and secure mode of the previousPath method --- .../RoutingUrlGeneratorPreviousPathTest.php | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/Routing/RoutingUrlGeneratorPreviousPathTest.php diff --git a/tests/Routing/RoutingUrlGeneratorPreviousPathTest.php b/tests/Routing/RoutingUrlGeneratorPreviousPathTest.php new file mode 100644 index 000000000000..1b0c57e0799f --- /dev/null +++ b/tests/Routing/RoutingUrlGeneratorPreviousPathTest.php @@ -0,0 +1,353 @@ +headers->set('referer', $referer); + } + + return new UrlGenerator($routes, $request); + } + + public function testPreviousPathWithSameDomainUrl() + { + $url = $this->getUrlGenerator('http://www.foo.com/bar/baz?query=value'); + + $this->assertSame('/bar/baz', $url->previousPath()); + } + + public function testPreviousPathWithSameDomainRootUrl() + { + $url = $this->getUrlGenerator('http://www.foo.com/'); + + $this->assertSame('/', $url->previousPath()); + } + + public function testPreviousPathWithSameDomainUrlAndQueryString() + { + $url = $this->getUrlGenerator('http://www.foo.com/products/123?category=electronics&sort=price'); + + $this->assertSame('/products/123', $url->previousPath()); + } + + public function testPreviousPathWithSameDomainUrlAndFragment() + { + $url = $this->getUrlGenerator('http://www.foo.com/docs/api#authentication'); + + $this->assertSame('/docs/api#authentication', $url->previousPath()); + } + + // backward compatibility tests - non-secure mode (old behavior) + public function testPreviousPathBackwardCompatibilityWithCrossDomain() + { + $url = $this->getUrlGenerator('http://evil.com/malicious'); + + $this->assertSame('http://evil.com/malicious', $url->previousPath()); + } + + public function testPreviousPathBackwardCompatibilityWithJavascriptSchemes() + { + $url = $this->getUrlGenerator('javascript:alert("xss")'); + + $this->assertSame('/javascript:alert("xss")', $url->previousPath()); + } + + public function testPreviousPathBackwardCompatibilityWithDataSchemes() + { + $url = $this->getUrlGenerator('data:text/html,'); + + $this->assertSame('/data:text/html,', $url->previousPath()); + } + + public function testPreviousPathBackwaCompatibilityWithDifferentSchemes() + { + $url = $this->getUrlGenerator('https://www.foo.com/secure/area', 'www.foo.com', 'http'); + + $this->assertSame('https://www.foo.com/secure/area', $url->previousPath()); + } + + public function testPreviousPathBackwardCompatibilityWithSubdomains() + { + $url = $this->getUrlGenerator('http://sub.foo.com/malicious'); + + $this->assertSame('http://sub.foo.com/malicious', $url->previousPath()); + } + + public function testPreviousPathBackwardCompatibilityWithDifferentPorts() + { + $url = $this->getUrlGenerator('http://www.foo.com:8080/admin', 'www.foo.com', 'http'); + + $this->assertSame(':8080/admin', $url->previousPath()); + } + + public function testPreviousPathBackwardCompatibilityWithExternalUrlAndFallback() + { + $url = $this->getUrlGenerator('https://evil.com/malicious'); + + $this->assertSame('https://evil.com/malicious', $url->previousPath('/dashboard')); + } + + // secure mode tests - enhanced behavior with secure flag + public function testPreviousPathSecureModeWithSameDomainUrl() + { + $url = $this->getUrlGenerator('http://www.foo.com/bar/baz?secure=true'); + + $this->assertSame('/bar/baz', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithSameDomainUrlAndFragment() + { + $url = $this->getUrlGenerator('http://www.foo.com/docs/api#authentication'); + + $this->assertSame('/docs/api', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithCrossDomain() + { + $url = $this->getUrlGenerator('http://evil.com/malicious'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithJavascriptSchemes() + { + $url = $this->getUrlGenerator('javascript:alert("xss")'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithDataSchemes() + { + $url = $this->getUrlGenerator('data:text/html,'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithFileSchemes() + { + $url = $this->getUrlGenerator('file:///etc/passwd'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeWithFallback() + { + $url = $this->getUrlGenerator('http://evil.com/malicious'); + + $this->assertSame('/home', $url->previousPath('/home', true)); + } + + public function testPreviousPathSecureModeBlocksExternalDomain() + { + $url = $this->getUrlGenerator('https://evil-site.com/malicious/path'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksExternalDomainWithAnyPaths() + { + $url = $this->getUrlGenerator('https://attacker.com/admin/users'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksDifferentScheme() + { + $url = $this->getUrlGenerator('https://www.foo.com/secure/area', 'www.foo.com', 'http'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeAllowsSameSchemeWithPaths() + { + $url = $this->getUrlGenerator('https://www.foo.com/secures/area', 'www.foo.com', 'https'); + + $this->assertSame('/secures/area', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksSubdomainAttack() + { + $url = $this->getUrlGenerator('http://sub.foo.com/malicious'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksDifferentPortBasedAttack() + { + $url = $this->getUrlGenerator('http://www.foo.com:8080/admin', 'www.foo.com', 'http'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeAllowsSameDomainWithSamePortUrl() + { + $url = $this->getUrlGenerator('http://www.foo.com:8080/allowed', 'www.foo.com:8080', 'http'); + + $this->assertSame('/allowed', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksDataUri() + { + $url = $this->getUrlGenerator('data:text/html,'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksJavascriptUri() + { + $url = $this->getUrlGenerator('javascript:alert("xss")'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeAllowsJavascriptUriIfOriginIsSame() + { + $url = $this->getUrlGenerator('http://www.foo.com/javascript:alert("same-origin")', 'www.foo.com'); + + $this->assertSame('/javascript:alert("same-origin")', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeBlocksFileUri() + { + $url = $this->getUrlGenerator('file:///etc/passwd'); + + $this->assertSame('/', $url->previousPath(false, true)); + } + + public function testPreviousPathHandlesEmptyReferer() + { + $url = $this->getUrlGenerator(''); + + $this->assertSame('/', $url->previousPath()); + } + + public function testPreviousPathHandlesNullReferer() + { + $url = $this->getUrlGenerator(null); + + $this->assertSame('/', $url->previousPath()); + } + + public function testPreviousPathSecureModeWithFallbackForExternalUrl() + { + $url = $this->getUrlGenerator('https://evil.com/malicious'); + + $this->assertSame('/dashboard', $url->previousPath('/dashboard', true)); + } + + public function testPreviousPathSecureModeAllowsFileUriIfOriginIsSame() + { + $url = $this->getUrlGenerator('http://www.foo.com/file:///etc/same'); + + $this->assertSame('/file:///etc/same', $url->previousPath(false, true)); + } + + public function testPreviousPathNormalizesTrailingSlashes() + { + $url = $this->getUrlGenerator('http://www.foo.com/path/to/resource/'); + + $this->assertSame('/path/to/resource', $url->previousPath()); + } + + public function testPreviousPathHandlesDeepNestedPaths() + { + $url = $this->getUrlGenerator('http://www.foo.com/level1/level2/level3/level4/list'); + + $this->assertSame('/level1/level2/level3/level4/list', $url->previousPath()); + } + + public function testPreviousPathHandlesSpecialCharactersInPath() + { + $url = $this->getUrlGenerator('http://www.foo.com/path-with-dashes/under_scores/123'); + + $this->assertSame('/path-with-dashes/under_scores/123', $url->previousPath()); + } + + public function testPreviousPathSecureModeHandlesCaseInsensitiveHost() + { + $url = $this->getUrlGenerator('http://WWW.FOO.COM/path', 'www.foo.com'); + + $this->assertSame('/path', $url->previousPath(false, true)); + } + + public function testPreviousPathSecureModeHandlesCaseVariations() + { + $url = $this->getUrlGenerator('http://www.FOO.com/path', 'www.foo.com'); + + $this->assertSame('/path', $url->previousPath(false, true)); + } + + public function testPreviousPathHandlesUrlWithoutPath() + { + $url = $this->getUrlGenerator('http://www.foo.com'); + + $this->assertSame('/', $url->previousPath()); + } + + public function testPreviousPathHandlesComplexQueryString() + { + $url = $this->getUrlGenerator('http://www.foo.com/search?q=php&framework=laravel&sort=popularity&page=34'); + + $this->assertSame('/search', $url->previousPath()); + } + + public function testPreviousPathHandlesEncodedCharacters() + { + $url = $this->getUrlGenerator('http://www.foo.com/path%20with%20spaces%20encoded/resource'); + + $this->assertSame('/path%20with%20spaces%20encoded/resource', $url->previousPath()); + } + + public function testIsSameOriginMethod() + { + $url = $this->getUrlGenerator(); + + $reflection = new \ReflectionClass($url); + $method = $reflection->getMethod('isSameOrigin'); + $method->setAccessible(true); + + $this->assertTrue($method->invoke( + $url, + ['host' => 'www.foo.com', 'scheme' => 'http'], + ['host' => 'www.foo.com', 'scheme' => 'http'] + )); + + // different host + $this->assertFalse($method->invoke( + $url, + ['host' => 'evil.com', 'scheme' => 'http'], + ['host' => 'www.foo.com', 'scheme' => 'http'] + )); + + // different scheme + $this->assertFalse($method->invoke( + $url, + ['host' => 'www.foo.com', 'scheme' => 'https'], + ['host' => 'www.foo.com', 'scheme' => 'http'] + )); + + // missing component + $this->assertFalse($method->invoke($url, false, ['host' => 'www.foo.com', 'scheme' => 'http'])); + $this->assertFalse($method->invoke($url, ['host' => 'www.foo.com'], ['host' => 'www.foo.com', 'scheme' => 'http'])); + + // case insensitive + $this->assertTrue($method->invoke( + $url, + ['host' => 'WWW.FOO.COM', 'scheme' => 'http'], + ['host' => 'www.foo.com', 'scheme' => 'http'] + )); + } +}