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 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.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..975442dd2f7f --- /dev/null +++ b/sapi/cli/tests/php_cli_server_024.phpt @@ -0,0 +1,146 @@ +--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 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 /leak.txt was not found on this server.

+ +---------- +/escape/secret-config.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 /escape/secret-config.txt was not found on this server.

+ +---------- +/neighbor.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 /neighbor.txt was not found on this server.

+ +---------- 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..6e41d5bb24f1 --- /dev/null +++ b/sapi/cli/tests/php_cli_server_025.phpt @@ -0,0 +1,119 @@ +--TEST-- +Built-in web server with root as the docroot +--SKIPIF-- + +--FILE-- +docRoot === $doc_root); + +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 + +----------