diff --git a/flight/template/View.php b/flight/template/View.php index 2e81ae3..a2fd8f8 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -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; - } - - // A drive letter or colon is an absolute jump, not a view name. - if (\strpos($segment, ':') !== false) { - return false; - } - - if ($segment === '..') { - if ($depth === 0) { - return false; - } + if ($this->restrictToPath === false) { + return true; + } - $depth--; - continue; - } + $root = \realpath($this->path); + $resolved = \realpath($template); - $depth++; + if ($root === false || $resolved === false) { + return false; } - 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 diff --git a/tests/ViewTest.php b/tests/ViewTest.php index ac360d2..b3638d5 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -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, '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, '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); + } }