From fd142ed04347233f1c32cb144aece139548f37c0 Mon Sep 17 00:00:00 2001 From: Daniel Scherzer Date: Mon, 28 Sep 2026 15:33:09 -0700 Subject: [PATCH 1/3] CLI server: add regression tests for symlinks escaping docroot The server properly prevents escaping the document root with path traversal, but does not account for escaping through the use of existing symlinks under the document root. Add a test so that when the behavior changes it is visible. --- sapi/cli/tests/php_cli_server.inc | 20 ++-- sapi/cli/tests/php_cli_server_024.phpt | 125 +++++++++++++++++++++++++ sapi/cli/tests/php_cli_server_025.phpt | 113 ++++++++++++++++++++++ 3 files changed, 252 insertions(+), 6 deletions(-) create mode 100644 sapi/cli/tests/php_cli_server_024.phpt create mode 100644 sapi/cli/tests/php_cli_server_025.phpt diff --git a/sapi/cli/tests/php_cli_server.inc b/sapi/cli/tests/php_cli_server.inc index 6e5449f5eb15..295e8c422b6b 100644 --- a/sapi/cli/tests/php_cli_server.inc +++ b/sapi/cli/tests/php_cli_server.inc @@ -13,14 +13,20 @@ class CliServerInfo { function php_cli_server_start( ?string $code = 'echo "Hello world";', ?string $router = 'index.php', - array $cmd_args = [] + array $cmd_args = [], + ?string $override_doc_root = null, ): CliServerInfo { $php_executable = getenv('TEST_PHP_EXECUTABLE') ?: PHP_BINARY; $error = null; - // Create dedicated doc root to avoid index.php clashes between processes. - $doc_root = __DIR__ . DIRECTORY_SEPARATOR . basename($_SERVER['PHP_SELF'], '.php') . '-' . getmypid(); - @mkdir($doc_root); + // Create dedicated doc root to avoid index.php clashes between processes, + // unless a specific root was requested + if ($override_doc_root === null) { + $doc_root = __DIR__ . DIRECTORY_SEPARATOR . basename($_SERVER['PHP_SELF'], '.php') . '-' . getmypid(); + @mkdir($doc_root); + } else { + $doc_root = $override_doc_root; + } if ($code) { file_put_contents($doc_root . '/' . ($router ?: 'index.php'), ''); @@ -100,7 +106,7 @@ function php_cli_server_start( } register_shutdown_function( - function($handle) use($router, $doc_root, $output_file) { + function($handle) use($router, $doc_root, $override_doc_root, $output_file) { proc_terminate($handle); $status = proc_get_status($handle); if ($status['exitcode'] !== -1 && $status['exitcode'] !== 0 @@ -109,7 +115,9 @@ function php_cli_server_start( printf("Server output:\n%s\n", file_get_contents($output_file)); } @unlink(__DIR__ . "/{$router}"); - remove_directory($doc_root); + if ($override_doc_root === null) { + remove_directory($doc_root); + } }, $handle ); diff --git a/sapi/cli/tests/php_cli_server_024.phpt b/sapi/cli/tests/php_cli_server_024.phpt new file mode 100644 index 000000000000..95fdcb331f85 --- /dev/null +++ b/sapi/cli/tests/php_cli_server_024.phpt @@ -0,0 +1,125 @@ +--TEST-- +Built-in web server docroot limit can be bypassed with symlinks +--SKIPIF-- + +--FILE-- +docRoot; + +file_put_contents($docRoot . '/public.txt', 'Public file contents'); + +$tmpFile = tempnam(sys_get_temp_dir(), "php_cli_server_024"); +@unlink($tmpFile); +mkdir($tmpFile); +file_put_contents($tmpFile . '/secret-config.txt', 'Secret credentials, NOT PUBLIC'); + +var_dump(symlink($tmpFile, $docRoot . '/escape')); +var_dump(symlink($tmpFile . '/secret-config.txt', $docRoot . '/leak.txt')); + +file_put_contents($docRoot . '-neighbor.txt', 'More secret credentials'); +var_dump(symlink($docRoot . '-neighbor.txt', $docRoot . '/neighbor.txt')); + +$targets = [ + "/public.txt", + "/../traversal.txt", + "/leak.txt", + "/escape/secret-config.txt", + "/neighbor.txt", +]; +foreach ( $targets as $target ) { + $fp = php_cli_server_connect(); + $req = << +--EXPECTF-- +bool(true) +bool(true) +bool(true) +/public.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Public file contents + +---------- +/../traversal.txt: +HTTP/1.1 404 Not Found +Date: %s +Connection: close +X-Powered-By: %s +Content-Type: text/html; charset=UTF-8 +Content-Length: %d + +404 Not Found +

Not Found

The requested resource /../traversal.txt was not found on this server.

+ +---------- +/leak.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Secret credentials, NOT PUBLIC + +---------- +/escape/secret-config.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Secret credentials, NOT PUBLIC + +---------- +/neighbor.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +More secret credentials + +---------- diff --git a/sapi/cli/tests/php_cli_server_025.phpt b/sapi/cli/tests/php_cli_server_025.phpt new file mode 100644 index 000000000000..7e13dd1ac82f --- /dev/null +++ b/sapi/cli/tests/php_cli_server_025.phpt @@ -0,0 +1,113 @@ +--TEST-- +Built-in web server with root as the docroot +--SKIPIF-- + +--FILE-- +docRoot === '/'); + +file_put_contents(__DIR__ . '/public.txt', 'Public file contents'); + +$tmpFile = tempnam(sys_get_temp_dir(), "php_cli_server_025"); +@unlink($tmpFile); +mkdir($tmpFile); +file_put_contents($tmpFile . '/secret-config.txt', 'Secret credentials, NOT PUBLIC'); + +symlink($tmpFile, __DIR__ . '/escape'); +symlink($tmpFile . '/secret-config.txt', __DIR__ . '/leak.txt'); + +$targets = [ + __DIR__ . "/public.txt", + __DIR__ . "/../traversal.txt", + __DIR__ . "/leak.txt", + __DIR__ . "/escape/secret-config.txt", +]; +foreach ( $targets as $target ) { + $fp = php_cli_server_connect(); + $req = << +--EXPECTF-- +%s/public.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Public file contents + +---------- +%s/../traversal.txt: +HTTP/1.1 404 Not Found +Date: %s +Connection: close +X-Powered-By: %s +Content-Type: text/html; charset=UTF-8 +Content-Length: %d + +404 Not Found +

Not Found

The requested resource %s/../traversal.txt was not found on this server.

+ +---------- +%s/leak.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Secret credentials, NOT PUBLIC + +---------- +%s/escape/secret-config.txt: +HTTP/1.1 200 OK +Date: %s +Connection: close +Content-Type: text/plain; charset=UTF-8 +Content-Length: %d + +Secret credentials, NOT PUBLIC + +---------- From f78f86119bb0669f52ab9c73063fe73941bef01c Mon Sep 17 00:00:00 2001 From: Daniel Scherzer Date: Mon, 28 Sep 2026 16:04:21 -0700 Subject: [PATCH 2/3] CLI server: prevent using symlinks to escape the docroot In `php_cli_server_request_translate_vpath()`, after translating the requested path, confirm that `realpath()` returns a path that is contained in the document root. --- sapi/cli/php_cli_server.c | 43 ++++++++++++++++++++++++++ sapi/cli/tests/php_cli_server_024.phpt | 39 +++++++++++++++++------ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/sapi/cli/php_cli_server.c b/sapi/cli/php_cli_server.c index c9ed8ff53c63..a887cc46ef30 100644 --- a/sapi/cli/php_cli_server.c +++ b/sapi/cli/php_cli_server.c @@ -1477,6 +1477,49 @@ static void php_cli_server_request_translate_vpath(const php_cli_server *server, return; } } + /* Regular file; check that it doesn't escape the docroot via a + * symlink */ + char *resolved = realpath(buf, NULL); + if (resolved == NULL) { + if (prev_path) { + pefree(prev_path, 1); + } + pefree(buf, 1); + return; + } + bool outside_docroot = strncmp(resolved, document_root, document_root_len) != 0; + // If the document root is `/` (or `\` on windows) then everything + // is in the docroot and we don't need to check that the document + // root is followed by a directory separator + if (!outside_docroot + && !( + document_root_len == 1 +#ifdef PHP_WIN32 + && document_root[0] == '\\' +#else + && document_root[0] == '/' +#endif + ) + ) { + // We know that the resolved path has at least the document_root + // as a prefix, and then a null terminating character, so reading + // at offset document_root_len is safe. We need to make sure + // that the file is actually *in* the document root, not just + // next to it with that name as a prefix +#ifdef PHP_WIN32 + outside_docroot = resolved[document_root_len] != '\\'; +#else + outside_docroot = resolved[document_root_len] != '/'; +#endif + } + free(resolved); + if (outside_docroot) { + if (prev_path) { + pefree(prev_path, 1); + } + pefree(buf, 1); + return; + } break; /* regular file */ } if (prev_path) { diff --git a/sapi/cli/tests/php_cli_server_024.phpt b/sapi/cli/tests/php_cli_server_024.phpt index 95fdcb331f85..975442dd2f7f 100644 --- a/sapi/cli/tests/php_cli_server_024.phpt +++ b/sapi/cli/tests/php_cli_server_024.phpt @@ -94,32 +94,53 @@ code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;} ---------- /leak.txt: -HTTP/1.1 200 OK +HTTP/1.1 404 Not Found Date: %s Connection: close -Content-Type: text/plain; charset=UTF-8 +X-Powered-By: %s +Content-Type: text/html; charset=UTF-8 Content-Length: %d -Secret credentials, NOT PUBLIC +404 Not Found +

Not Found

The requested resource /leak.txt was not found on this server.

---------- /escape/secret-config.txt: -HTTP/1.1 200 OK +HTTP/1.1 404 Not Found Date: %s Connection: close -Content-Type: text/plain; charset=UTF-8 +X-Powered-By: %s +Content-Type: text/html; charset=UTF-8 Content-Length: %d -Secret credentials, NOT PUBLIC +404 Not Found +

Not Found

The requested resource /escape/secret-config.txt was not found on this server.

---------- /neighbor.txt: -HTTP/1.1 200 OK +HTTP/1.1 404 Not Found Date: %s Connection: close -Content-Type: text/plain; charset=UTF-8 +X-Powered-By: %s +Content-Type: text/html; charset=UTF-8 Content-Length: %d -More secret credentials +404 Not Found +

Not Found

The requested resource /neighbor.txt was not found on this server.

---------- From b4e9c45c45ea8f6a87cc2e1ac2ee96344f7d70d1 Mon Sep 17 00:00:00 2001 From: Daniel Scherzer Date: Mon, 28 Sep 2026 16:05:09 -0700 Subject: [PATCH 3/3] CLI server: document eliminating symlink docroot escape in UPGRADING --- UPGRADING | 2 ++ 1 file changed, 2 insertions(+) diff --git a/UPGRADING b/UPGRADING index 2d48a1da05ca..685c142842ec 100644 --- a/UPGRADING +++ b/UPGRADING @@ -549,6 +549,8 @@ PHP 8.6 UPGRADE NOTES method instead of returning 501 Not Implemented. . The built-in development server no longer reflects the "Host" header from requests. + . The built-in development server no longer allows escaping the document root + by following symlinks. ======================================== 4. Deprecated Functionality