feat: make view path containment opt-in with View::$restrictToPath

Restore getTemplate() and its existing test exactly as on master. Default behavior is unchanged: absolute paths still render and exists() never throws. With restrictToPath on, render(), fetch() and exists() only accept files that resolve inside the views path. A blocked file has its own error message, separate from "not found".
pull/729/head
Ambrose Casanova 3 days ago
parent 8093a18fb3
commit eac9fef021

@ -22,6 +22,12 @@ class View
public bool $preserveVars = true;
/**
* When true, render(), fetch() and exists() only accept template files
* that resolve inside $path. Off by default so existing behavior holds.
*/
public bool $restrictToPath = false;
/**
* View variables.
*
@ -116,6 +122,10 @@ class View
throw new \Exception("Template file not found: {$normalized_path}.");
}
if (!$this->isInsidePath($this->template)) {
throw new \Exception('Template file is outside the views path.');
}
\extract($this->vars);
if (\is_array($templateData) === true) {
@ -155,20 +165,17 @@ class View
*/
public function exists(string $file): bool
{
return \file_exists($this->getTemplate($file));
$template = $this->getTemplate($file);
return \file_exists($template) && $this->isInsidePath($template);
}
/**
* Gets the full path to a template file.
*
* Absolute paths are rejected. The resolved file must stay inside the
* configured views directory. If that cannot be shown, this fails closed.
*
* @param string $file Template file
*
* @return string Template file location
*
* @throws \Exception When the path is absolute or resolves outside the views directory.
*/
public function getTemplate(string $file): string
{
@ -178,90 +185,50 @@ class View
$file .= $ext;
}
if ($this->isAbsolutePath($file)) {
throw new \Exception('Template path is not allowed.');
}
$viewsPath = \realpath($this->path);
if ($viewsPath === false || !$this->relativeStaysInside($file)) {
throw new \Exception('Template path is not allowed.');
}
$candidate = $this->path . \DIRECTORY_SEPARATOR . $file;
$resolved = \realpath($candidate);
if ($resolved === false) {
return $candidate;
}
$is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN';
$root = \rtrim($viewsPath, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR;
if (\strpos($resolved, $root) !== 0) {
throw new \Exception('Template path is not allowed.');
if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) {
return $file;
}
return $resolved;
return $this->path . DIRECTORY_SEPARATOR . $file;
}
/**
* True when $file is an absolute filesystem path.
* Displays escaped output.
*
* @param string $str String to escape
*
* @return string Escaped string
*/
private function isAbsolutePath(string $file): bool
public function e(string $str): string
{
if ($file === '') {
return false;
}
if ($file[0] === '/' || $file[0] === '\\') {
return true;
}
return \strlen($file) > 1 && \ctype_alpha($file[0]) && $file[1] === ':';
$value = \htmlentities($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo $value;
return $value;
}
/**
* True when relative segments in $file do not climb out of the views directory.
* Checks that an existing template file resolves inside the views path.
* Always true while $restrictToPath is off. Fails closed when either
* path cannot be resolved.
*/
private function relativeStaysInside(string $file): bool
protected function isInsidePath(string $template): bool
{
$segments = \explode('/', \str_replace('\\', '/', $file));
$depth = 0;
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.') {
continue;
if ($this->restrictToPath === false) {
return true;
}
// A drive letter or colon is an absolute jump, not a view name.
if (\strpos($segment, ':') !== false) {
return false;
}
$root = \realpath($this->path);
$resolved = \realpath($template);
if ($segment === '..') {
if ($depth === 0) {
if ($root === false || $resolved === false) {
return false;
}
$depth--;
continue;
}
$depth++;
}
return true;
}
$root = \rtrim($root, '\\/') . DIRECTORY_SEPARATOR;
/**
* Displays escaped output.
*
* @param string $str String to escape
*
* @return string Escaped string
*/
public function e(string $str): string
{
$value = \htmlentities($str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo $value;
return $value;
return \strpos($resolved, $root) === 0;
}
protected static function normalizePath(string $path, string $separator = DIRECTORY_SEPARATOR): string

@ -111,118 +111,12 @@ class ViewTest extends TestCase
$this->expectOutputString("Hello world, Bob!");
}
public function testRenderRelativePathThatStaysInsideViews(): void
{
$this->view->render('layouts/../hello', ['name' => 'Bob']);
$this->expectOutputString('Hello, Bob!');
}
public function testGetTemplateAbsolutePath(): void
{
$tmpfile = tmpfile();
$this->view->extension = '';
$file_path = stream_get_meta_data($tmpfile)['uri'];
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$this->view->getTemplate($file_path);
}
public function testRejectsDriveLetterTemplatePath(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$this->view->getTemplate('C:' . DIRECTORY_SEPARATOR . 'outside.php');
}
public function testRejectsTemplateThatLeavesViewsDirectory(): void
{
$outside = dirname($this->view->path) . DIRECTORY_SEPARATOR . 'flight-view-outside-' . uniqid();
mkdir($outside);
$note = $outside . DIRECTORY_SEPARATOR . 'note.php';
file_put_contents($note, '<?php echo "blocked";');
$relative = '..' . DIRECTORY_SEPARATOR . basename($outside) . DIRECTORY_SEPARATOR . 'note';
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$this->view->render($relative);
} finally {
unlink($note);
rmdir($outside);
}
}
public function testRejectsEmbeddedDriveLetter(): void
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$this->view->getTemplate('layouts' . DIRECTORY_SEPARATOR . 'C:' . DIRECTORY_SEPARATOR . 'outside');
}
public function testRejectsTemplateThatResolvesOutsideViewsDirectory(): void
{
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-view-root-' . uniqid();
$views = $root . DIRECTORY_SEPARATOR . 'views';
$outside = $root . DIRECTORY_SEPARATOR . 'outside';
mkdir($root);
mkdir($views);
mkdir($outside);
$note = $outside . DIRECTORY_SEPARATOR . 'note.php';
file_put_contents($note, '<?php echo "blocked";');
$link = $views . DIRECTORY_SEPARATOR . 'alias.php';
if (!@symlink($note, $link)) {
$this->removeDir($root);
$this->markTestSkipped('Symlink not available');
}
$view = new View($views);
try {
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$view->render('alias');
} finally {
$this->removeDir($root);
}
}
public function testRejectsMissingViewsDirectory(): void
{
$view = new View(sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-missing-views-' . uniqid());
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template path is not allowed.');
$view->render('hello');
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_link($path) || is_file($path)) {
unlink($path);
continue;
}
$this->removeDir($path);
}
rmdir($dir);
$this->assertEquals($file_path, $this->view->getTemplate($file_path));
}
public function testE(): void
@ -361,4 +255,139 @@ class ViewTest extends TestCase
],
];
}
public function testAbsolutePathStillRendersByDefault(): void
{
$file = $this->makeOutsideTemplate('outside');
try {
$this->expectOutputString('outside');
$this->view->render($file);
$this->assertTrue($this->view->exists($file));
} finally {
$this->removeDir(dirname($file));
}
}
public function testRestrictToPathIsOffByDefault(): void
{
$this->assertFalse((new View())->restrictToPath);
}
public function testRestrictToPathRendersViewInsidePath(): void
{
$this->view->restrictToPath = true;
$this->expectOutputString('Hello, Bob!');
$this->view->render('hello', ['name' => 'Bob']);
$this->assertTrue($this->view->exists('hello'));
$this->assertTrue($this->view->exists('layouts/layout'));
}
public function testRestrictToPathAllowsAbsolutePathInsidePath(): void
{
$this->view->restrictToPath = true;
$file = (string) realpath(__DIR__ . '/views/hello.php');
$this->expectOutputString('Hello, Bob!');
$this->view->render($file, ['name' => 'Bob']);
}
public function testRestrictToPathRejectsRelativePathOutsidePath(): void
{
$this->view->restrictToPath = true;
$dir = __DIR__ . DIRECTORY_SEPARATOR . 'restrict-' . uniqid();
mkdir($dir);
file_put_contents($dir . DIRECTORY_SEPARATOR . 'note.php', 'outside');
$name = '..' . DIRECTORY_SEPARATOR . basename($dir) . DIRECTORY_SEPARATOR . 'note';
try {
$this->assertFalse($this->view->exists($name));
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template file is outside the views path.');
$this->view->render($name);
} finally {
$this->removeDir($dir);
}
}
public function testRestrictToPathRejectsAbsolutePathOutsidePath(): void
{
$this->view->restrictToPath = true;
$file = $this->makeOutsideTemplate('outside');
try {
$this->assertFalse($this->view->exists($file));
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template file is outside the views path.');
$this->view->render($file);
} finally {
$this->removeDir(dirname($file));
}
}
public function testRestrictToPathRejectsSymlinkOutsidePath(): void
{
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-view-' . uniqid();
mkdir($root . DIRECTORY_SEPARATOR . 'views', 0777, true);
mkdir($root . DIRECTORY_SEPARATOR . 'outside');
$target = $root . DIRECTORY_SEPARATOR . 'outside' . DIRECTORY_SEPARATOR . 'note.php';
file_put_contents($target, 'outside');
if (!@symlink($target, $root . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'alias.php')) {
$this->removeDir($root);
$this->markTestSkipped('Symlinks are not available.');
}
$view = new View($root . DIRECTORY_SEPARATOR . 'views');
$view->restrictToPath = true;
try {
$this->assertFalse($view->exists('alias'));
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template file is outside the views path.');
$view->render('alias');
} finally {
$this->removeDir($root);
}
}
public function testRestrictToPathKeepsNotFoundMessage(): void
{
$this->view->restrictToPath = true;
$this->assertFalse($this->view->exists('badfile'));
$this->expectException(Exception::class);
$this->expectExceptionMessage('Template file not found:');
$this->view->render('badfile');
}
private function makeOutsideTemplate(string $content): string
{
$dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-view-' . uniqid();
mkdir($dir);
$file = $dir . DIRECTORY_SEPARATOR . 'note.php';
file_put_contents($file, $content);
return $file;
}
private function removeDir(string $dir): void
{
foreach ((array) scandir($dir) as $item) {
if ($item === '.' || $item === '..' || $item === false) {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path) && !is_link($path)) {
$this->removeDir($path);
} else {
unlink($path);
}
}
rmdir($dir);
}
}

Loading…
Cancel
Save