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
39 changes: 28 additions & 11 deletions cli/Valet/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<h1>Index of $uri</h1>";
echo '<hr>';
echo implode(
'<br>'.PHP_EOL,
array_map(function ($path) use ($uri, $isRoot) {
$file = basename($path);
return ($isRoot) ? "<a href='/$file'>/$file</a>" : "<a href='$uri/$file'>$uri/$file/</a>";
}, $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<int, string> $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
? "<a href='/$fileEscaped'>/$fileEscaped</a>"
: "<a href='$uriEscaped/$fileEscaped'>$uriEscaped/$fileEscaped/</a>";
}, $paths);

return '<h1>Index of '.$uriEscaped.'</h1>'
.'<hr>'
.implode('<br>'.PHP_EOL, $links);
}

/**
* Return whether a given host (from $_SERVER['HTTP_HOST']) is an IP address.
*/
Expand Down
10 changes: 0 additions & 10 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -460,21 +460,11 @@ parameters:
count: 1
path: cli/Valet/Server.php

-
message: "#^Parameter \\#1 \\$array of function usort expects TArray of array\\<string\\>, array\\<int, string\\>\\|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\\<int, string\\>\\|false given\\.$#"
count: 1
path: cli/Valet/Server.php

-
message: "#^Parameter \\#2 \\$string of function explode expects string, string\\|null given\\.$#"
count: 1
Expand Down
33 changes: 33 additions & 0 deletions tests/Unit/ServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('/<script>alert(1)</script>', [], false);

$this->assertStringNotContainsString('<script>', $html);
$this->assertStringContainsString('&lt;script&gt;', $html);
}

/**
* @test
*/
public function it_escapes_file_names_in_the_directory_listing(): void
{
// A crafted directory entry must not break out of the href or inject markup.
$html = Server::directoryListingHtml('/', ['/site/"><img src=x onerror=alert(1)>'], true);

$this->assertStringNotContainsString('<img', $html);
$this->assertStringContainsString('&lt;img', $html);
}

/**
* @test
*/
public function it_builds_plain_links_for_normal_entries(): void
{
$html = Server::directoryListingHtml('/', ['/site/index.php'], true);

$this->assertStringContainsString("<a href='/index.php'>/index.php</a>", $html);
}
}
Loading