diff --git a/flight/template/View.php b/flight/template/View.php index 17622fd5..2e81ae30 100644 --- a/flight/template/View.php +++ b/flight/template/View.php @@ -161,9 +161,14 @@ public function exists(string $file): bool /** * 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 { @@ -173,13 +178,76 @@ public function getTemplate(string $file): string $file .= $ext; } - $is_windows = \strtoupper(\substr(PHP_OS, 0, 3)) === 'WIN'; + 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; + } + + $root = \rtrim($viewsPath, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR; + if (\strpos($resolved, $root) !== 0) { + throw new \Exception('Template path is not allowed.'); + } + + return $resolved; + } + + /** + * True when $file is an absolute filesystem path. + */ + private function isAbsolutePath(string $file): bool + { + if ($file === '') { + return false; + } + + if ($file[0] === '/' || $file[0] === '\\') { + return true; + } + + return \strlen($file) > 1 && \ctype_alpha($file[0]) && $file[1] === ':'; + } + + /** + * True when relative segments in $file do not climb out of the views directory. + */ + private function relativeStaysInside(string $file): 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; + } + + $depth--; + continue; + } - if ((\substr($file, 0, 1) === '/') || ($is_windows && \substr($file, 1, 1) === ':')) { - return $file; + $depth++; } - return $this->path . DIRECTORY_SEPARATOR . $file; + return true; } /** diff --git a/tests/ViewTest.php b/tests/ViewTest.php index 56a6e0e6..ac360d22 100644 --- a/tests/ViewTest.php +++ b/tests/ViewTest.php @@ -111,12 +111,118 @@ public function testTemplateWithCustomExtension(): void $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->assertEquals($file_path, $this->view->getTemplate($file_path)); + + $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); } public function testE(): void