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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 64 additions & 0 deletions bin/serve.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion examples/001-SimpleWeb/example.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<!DOCTYPE html><html><body>';
echo '<!DOCTYPE html><html><head><link rel="stylesheet" href="/style.css"></head><body>';
echo '<h1>Hello ', htmlspecialchars($name), "</h1>\n";
echo '</body></html>';
2 changes: 2 additions & 0 deletions examples/001-SimpleWeb/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
body { font-family: system-ui, sans-serif; margin: 2rem; }
h1 { color: #2563eb; }
17 changes: 17 additions & 0 deletions test/real/ServeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ public function testUncaughtExceptionDebugModeIncludesClass(): void
$this->assertStringContainsString('no_such_func', $response);
}

public function testServesStaticCssFromDocroot(): void
{
$docroot = $this->makeDocroot(['style.css' => 'body { color: navy; }']);
$response = $this->httpGet($docroot, '/style.css');
$this->assertStringContainsString('HTTP/1.1 200', $response);
$this->assertStringContainsString('Content-Type: text/css', $response);
$this->assertStringContainsString('body { color: navy; }', $response);
}

public function testRejectsPathTraversal(): void
{
$docroot = $this->makeDocroot(['secret.txt' => 'hidden']);
$response = $this->httpGet($docroot, '/../secret.txt');
$this->assertStringContainsString('HTTP/1.1 403', $response);
$this->assertStringNotContainsString('hidden', $response);
}

/**
* @param array<string, string> $extraEnv
*/
Expand Down