Merge pull request #729 from flightphp/fix/view-template-containment

feat: opt-in flight.views.restrict_to_path to keep templates inside the views path
master v3.19.4
n0nag0n 12 hours ago committed by GitHub
commit 06f10a7eb1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -188,6 +188,7 @@ class Engine
$this->loader->register('view', View::class, [], function (View $view) use ($self) {
$view->path = $self->get('flight.views.path');
$view->extension = $self->get('flight.views.extension');
$view->restrictToPath = (bool) $self->get('flight.views.restrict_to_path');
});
foreach (self::MAPPABLE_METHODS as $name) {
@ -202,6 +203,7 @@ class Engine
$this->set('flight.debug', false);
$this->set('flight.views.path', './views');
$this->set('flight.views.extension', '.php');
$this->set('flight.views.restrict_to_path', false);
$this->set('flight.content_length', true);
$this->set('flight.v2.output_buffering', false);
$this->set('flight.allow_method_override', true);

@ -22,6 +22,13 @@ 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.
* Prefer Flight::set('flight.views.restrict_to_path', true); Engine applies it.
*/
public bool $restrictToPath = false;
/**
* View variables.
*
@ -116,6 +123,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,7 +166,9 @@ 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);
}
/**
@ -196,6 +209,29 @@ class View
return $value;
}
/**
* 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.
*/
protected function isInsidePath(string $template): bool
{
if ($this->restrictToPath === false) {
return true;
}
$root = \realpath($this->path);
$resolved = \realpath($template);
if ($root === false || $resolved === false) {
return false;
}
$root = \rtrim($root, '\\/') . DIRECTORY_SEPARATOR;
return \strpos($resolved, $root) === 0;
}
protected static function normalizePath(string $path, string $separator = DIRECTORY_SEPARATOR): string
{
return \str_replace(['\\', '/'], $separator, $path);

@ -1380,4 +1380,15 @@ class EngineTest extends TestCase
$this->assertSame('HEAD', $engine->request()->method);
}
public function testViewsRestrictToPathConfigIsAppliedToView(): void
{
$engine = new Engine();
$this->assertFalse($engine->view()->restrictToPath);
$engine = new Engine();
$engine->set('flight.views.restrict_to_path', true);
$this->assertTrue($engine->view()->restrictToPath);
}
}

@ -255,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