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
pull/730/head v3.19.4
n0nag0n 3 weeks 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