diff --git a/README.md b/README.md index 3852fff4365..5d3d9265856 100755 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ curl 'http://127.0.0.1:8080/example.php?name=Dev' Uncaught errors return HTTP 500 with a generic body. Set `PHP_COMPILER_DEBUG=1` to include the exception class, message, and stack trace in the response (details are always logged to stderr). +Non-`.php` files under the docroot (for example `style.css`) are served as static assets with a guessed `Content-Type`; path segments containing `..` are rejected. + ## Using docker Docker is optional. The Makefile targets Ubuntu 16.04 and 18.04 images with PHP 7.4 for historical compatibility. For day-to-day development, prefer the host workflow above. Use `make test-18` for the 18.04 image once built. diff --git a/bin/serve.php b/bin/serve.php index 7b71da8bd11..e8b4171a771 100755 --- a/bin/serve.php +++ b/bin/serve.php @@ -72,6 +72,27 @@ function handleConnection($conn, string $docroot): void $path = '/example.php'; } + if (!isSafeUrlPath($path)) { + respond($conn, 403, 'text/plain', "Forbidden\n"); + + return; + } + + if (!str_ends_with(strtolower($path), '.php')) { + $static = resolveDocrootFile($docroot, $path); + if (null !== $static) { + $bytes = file_get_contents($static); + if (false === $bytes) { + respond($conn, 500, 'text/plain', "Internal Server Error\n"); + + return; + } + respond($conn, 200, guessContentType($static), $bytes); + + return; + } + } + $scriptName = $path; $pathInfo = ''; if (preg_match('#^(.+\.php)(/.*)?$#', $path, $pm)) { @@ -189,6 +210,48 @@ function readRequest($conn): ?array return [$method, $path, $query, $headers, $body]; } +function isSafeUrlPath(string $path): bool +{ + if ('' === $path || '/' !== $path[0]) { + return false; + } + foreach (explode('/', $path) as $segment) { + if ('..' === $segment) { + return false; + } + } + + return true; +} + +function resolveDocrootFile(string $docroot, string $urlPath): ?string +{ + $candidate = $docroot . $urlPath; + $real = realpath($candidate); + if (false === $real || !is_file($real)) { + return null; + } + $prefix = $docroot . DIRECTORY_SEPARATOR; + if ($real !== $docroot && !str_starts_with($real, $prefix)) { + return null; + } + + return $real; +} + +function guessContentType(string $filePath): string +{ + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + return [ + 'css' => 'text/css; charset=UTF-8', + 'js' => 'application/javascript; charset=UTF-8', + 'png' => 'image/png', + 'svg' => 'image/svg+xml', + 'ico' => 'image/x-icon', + ][$ext] ?? 'application/octet-stream'; +} + function isServeDebug(): bool { $v = getenv('PHP_COMPILER_DEBUG'); @@ -216,6 +279,7 @@ function respond($conn, int $status, string $contentType, string $body, array $e $reason = [ 200 => 'OK', 400 => 'Bad Request', + 403 => 'Forbidden', 404 => 'Not Found', 500 => 'Internal Server Error', ][$status] ?? 'OK'; diff --git a/examples/001-SimpleWeb/example.php b/examples/001-SimpleWeb/example.php index 29e415a6bff..a53b25bc9c9 100644 --- a/examples/001-SimpleWeb/example.php +++ b/examples/001-SimpleWeb/example.php @@ -15,6 +15,6 @@ // $_GET is populated from that string during compilation (see SuperglobalInit). $name = $_GET['name']; header('Content-Type: text/html; charset=UTF-8'); -echo '
'; +echo ''; echo '