diff --git a/cli/Valet/Server.php b/cli/Valet/Server.php index edccf06..224037d 100644 --- a/cli/Valet/Server.php +++ b/cli/Valet/Server.php @@ -75,25 +75,42 @@ public static function showDirectoryListing(string $valetSitePath, string $uri) } // Sort directories at the top - $paths = glob("$directory/*"); + $paths = glob("$directory/*") ?: []; usort($paths, function ($a, $b) { return (is_dir($a) == is_dir($b)) ? strnatcasecmp($a, $b) : (is_dir($a) ? -1 : 1); }); - // Output the HTML for the directory listing - echo "

Index of $uri

"; - echo '
'; - echo implode( - '
'.PHP_EOL, - array_map(function ($path) use ($uri, $isRoot) { - $file = basename($path); - return ($isRoot) ? "/$file" : "$uri/$file/"; - }, $paths) - ); + echo static::directoryListingHtml($uri, $paths, $isRoot); exit; } + /** + * Build the HTML for a directory listing. + * + * The request URI and the file names are HTML-escaped: both are + * attacker-influenceable (the URI directly, a file name via a crafted + * directory entry), so echoing them raw would allow reflected/stored XSS. + * + * @param array $paths + */ + public static function directoryListingHtml(string $uri, array $paths, bool $isRoot): string + { + $uriEscaped = htmlspecialchars($uri, ENT_QUOTES, 'UTF-8'); + + $links = array_map(function ($path) use ($uriEscaped, $isRoot) { + $fileEscaped = htmlspecialchars(basename($path), ENT_QUOTES, 'UTF-8'); + + return $isRoot + ? "/$fileEscaped" + : "$uriEscaped/$fileEscaped/"; + }, $paths); + + return '

Index of '.$uriEscaped.'

' + .'
' + .implode('
'.PHP_EOL, $links); + } + /** * Return whether a given host (from $_SERVER['HTTP_HOST']) is an IP address. */ diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f292770..3651df3 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -460,21 +460,11 @@ parameters: count: 1 path: cli/Valet/Server.php - - - message: "#^Parameter \\#1 \\$array of function usort expects TArray of array\\, array\\\\|false given\\.$#" - count: 1 - path: cli/Valet/Server.php - - message: "#^Parameter \\#1 \\$haystack of function strpos expects string, string\\|null given\\.$#" count: 1 path: cli/Valet/Server.php - - - message: "#^Parameter \\#2 \\$array of function array_map expects array, array\\\\|false given\\.$#" - count: 1 - path: cli/Valet/Server.php - - message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|null given\\.$#" count: 1 diff --git a/tests/Unit/ServerTest.php b/tests/Unit/ServerTest.php index 1e241dc..78fbeaa 100644 --- a/tests/Unit/ServerTest.php +++ b/tests/Unit/ServerTest.php @@ -121,4 +121,37 @@ public function it_returns_null_when_the_ip_address_uri_has_no_matching_tld(): v $this->assertNull(Server::valetSiteFromIpAddressUri('my-site.dev/auth', 'test')); $this->assertNull(Server::valetSiteFromIpAddressUri('', 'test')); } + + /** + * @test + */ + public function it_escapes_the_uri_in_the_directory_listing(): void + { + $html = Server::directoryListingHtml('/', [], false); + + $this->assertStringNotContainsString('