diff --git a/src/Http/Controllers/ShowcaseAssetController.php b/src/Http/Controllers/ShowcaseAssetController.php index c9fd7559..269f68ff 100644 --- a/src/Http/Controllers/ShowcaseAssetController.php +++ b/src/Http/Controllers/ShowcaseAssetController.php @@ -69,12 +69,16 @@ class ShowcaseAssetController } /** - * A font or an SVG the stylesheet's `url()`s point at, MIME-typed by extension: `..` and - * anything the two folders above do not contain resolves to nothing and 404s, the same as an - * unlisted extension (the font's own `OFL.txt`, sitting right beside it, included). + * A font or an SVG the stylesheet's `url()`s point at, MIME-typed by extension. The path is + * resolved on disk first, so `..` (however it was encoded), a symbolic link and anything else + * that lands outside the two folders above 404s, as does a directory, a null byte (which + * `realpath()` would throw on) and an unlisted extension (the font's own `OFL.txt`, sitting + * right beside it, included). */ public function file(string $path): BinaryFileResponse { + abort_if(str_contains($path, "\0"), 404); + $segments = explode('/', $path, 2); $folder = $segments[0] ?? null; $rest = $segments[1] ?? null; @@ -84,7 +88,7 @@ class ShowcaseAssetController $root = realpath(dirname(__DIR__, 3)."/resources/{$folder}"); $real = $root === false ? false : realpath("{$root}/{$rest}"); - abort_unless($real !== false && str_starts_with($real, $root.DIRECTORY_SEPARATOR), 404); + abort_unless($real !== false && str_starts_with($real, $root.DIRECTORY_SEPARATOR) && is_file($real), 404); $mime = self::MIME_TYPES[strtolower(pathinfo($real, PATHINFO_EXTENSION))] ?? null; diff --git a/tests/Feature/ShowcaseAssetsTest.php b/tests/Feature/ShowcaseAssetsTest.php index ec4dae63..2b3ee7a8 100644 --- a/tests/Feature/ShowcaseAssetsTest.php +++ b/tests/Feature/ShowcaseAssetsTest.php @@ -54,6 +54,31 @@ it('404s a path that climbs out of the two served folders', function () { $this->get('/material/assets/fonts/../../composer.json')->assertNotFound(); }); +it('404s every other way out of the two served folders', function (string $path) { + $this->get('/material/assets/'.$path)->assertNotFound(); +})->with([ + 'encoded dot segments' => 'fonts/%2e%2e/%2e%2e/composer.json', + 'encoded slashes' => 'fonts/..%2f..%2fcomposer.json', + 'backslashes' => 'fonts/google-sans-flex/..%5c..%5c..%5ccomposer.json', + 'an absolute path' => '/etc/hosts', + 'an absolute path inside a folder' => 'fonts//etc/hosts', + 'a null byte' => 'fonts/google-sans-flex/GoogleSansFlex-Latin.woff2%00.txt', + 'a directory' => 'svg/symbols', + 'a folder itself' => 'fonts/', + 'a very long path' => 'fonts/'.str_repeat('a/', 3000).'x.woff2', +]); + +it('404s a symbolic link inside a served folder that points out of it', function () { + $link = __DIR__.'/../../resources/svg/showcase-assets-test-link.svg'; + symlink(realpath(__DIR__.'/../../composer.json'), $link); + + try { + $this->get('/material/assets/svg/showcase-assets-test-link.svg')->assertNotFound(); + } finally { + unlink($link); + } +}); + it('404s a folder the showcase does not serve', function () { $this->get('/material/assets/css/all.css')->assertNotFound(); });