Skip to content
Open
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 UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions sapi/cli/php_cli_server.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
DanielEScherzer marked this conversation as resolved.
// 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) {
Expand Down
20 changes: 14 additions & 6 deletions sapi/cli/tests/php_cli_server.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '<?php ' . $code . ' ?>');
Expand Down Expand Up @@ -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
Expand All @@ -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
);
Expand Down
146 changes: 146 additions & 0 deletions sapi/cli/tests/php_cli_server_024.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
--TEST--
Built-in web server docroot limit can be bypassed with symlinks
--SKIPIF--
<?php
include "skipif.inc";
?>
--FILE--
<?php
include "php_cli_server.inc";

$server = php_cli_server_start(null, null);
$docRoot = $server->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 = <<<END
GET $target HTTP/1.1



END;

echo "$target:\n";
if (fwrite($fp, $req)) {
while (!feof($fp)) {
echo fgets($fp);
}
}
echo "\n\n----------\n";
fclose($fp);
}

unlink($docRoot . '/public.txt');
unlink($docRoot . '/leak.txt');
// On Windows, to delete a symlink to a directory, rmdir() has to be used instead.
if (PHP_OS_FAMILY === 'Windows') {
rmdir($docRoot . '/escape');
} else {
unlink($docRoot . '/escape');
}
unlink($docRoot . '/neighbor.txt');
unlink($docRoot . '-neighbor.txt');

?>
--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

<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title><style>
body { background-color: #fcfcfc; color: #333333; margin: 0; padding:0; }
h1 { font-size: 1.5em; font-weight: normal; background-color: #9999cc; min-height:2em; line-height:2em; border-bottom: 1px inset black; margin: 0; }
h1, p { padding-left: 10px; }
code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;}
</style>
</head><body><h1>Not Found</h1><p>The requested resource <code class="url">/../traversal.txt</code> was not found on this server.</p></body></html>

----------
/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

<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title><style>
body { background-color: #fcfcfc; color: #333333; margin: 0; padding:0; }
h1 { font-size: 1.5em; font-weight: normal; background-color: #9999cc; min-height:2em; line-height:2em; border-bottom: 1px inset black; margin: 0; }
h1, p { padding-left: 10px; }
code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;}
</style>
</head><body><h1>Not Found</h1><p>The requested resource <code class="url">/leak.txt</code> was not found on this server.</p></body></html>

----------
/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

<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title><style>
body { background-color: #fcfcfc; color: #333333; margin: 0; padding:0; }
h1 { font-size: 1.5em; font-weight: normal; background-color: #9999cc; min-height:2em; line-height:2em; border-bottom: 1px inset black; margin: 0; }
h1, p { padding-left: 10px; }
code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;}
</style>
</head><body><h1>Not Found</h1><p>The requested resource <code class="url">/escape/secret-config.txt</code> was not found on this server.</p></body></html>

----------
/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

<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title><style>
body { background-color: #fcfcfc; color: #333333; margin: 0; padding:0; }
h1 { font-size: 1.5em; font-weight: normal; background-color: #9999cc; min-height:2em; line-height:2em; border-bottom: 1px inset black; margin: 0; }
h1, p { padding-left: 10px; }
code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;}
</style>
</head><body><h1>Not Found</h1><p>The requested resource <code class="url">/neighbor.txt</code> was not found on this server.</p></body></html>

----------
113 changes: 113 additions & 0 deletions sapi/cli/tests/php_cli_server_025.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
--TEST--
Built-in web server with root as the docroot
--SKIPIF--
<?php
include "skipif.inc";
?>
--FILE--
<?php
include "php_cli_server.inc";

$server = php_cli_server_start(null, null, [], "/");
assert($server->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 = <<<END
GET $target HTTP/1.1



END;

echo "$target:\n";
if (fwrite($fp, $req)) {
$had_response = false;
while (!feof($fp)) {
$had_response = true;
echo fgets($fp);
}
if (!$had_response) {
echo "No response from server?\n";
}
} else {
echo "fwrite() failed?\n";
}
echo "\n\n----------\n";
fclose($fp);
}

unlink(__DIR__ . '/public.txt');
unlink(__DIR__ . '/leak.txt');
// On Windows, to delete a symlink to a directory, rmdir() has to be used instead.
if (PHP_OS_FAMILY === 'Windows') {
rmdir(__DIR__ . '/escape');
} else {
unlink(__DIR__ . '/escape');
}

?>
--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

<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 Not Found</title><style>
body { background-color: #fcfcfc; color: #333333; margin: 0; padding:0; }
h1 { font-size: 1.5em; font-weight: normal; background-color: #9999cc; min-height:2em; line-height:2em; border-bottom: 1px inset black; margin: 0; }
h1, p { padding-left: 10px; }
code.url { background-color: #eeeeee; font-family:monospace; padding:0 2px;}
</style>
</head><body><h1>Not Found</h1><p>The requested resource <code class="url">%s/../traversal.txt</code> was not found on this server.</p></body></html>

----------
%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

----------
Loading