Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 72 additions & 4 deletions flight/template/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
}

/**
Expand Down
108 changes: 107 additions & 1 deletion tests/ViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<?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);
}

public function testE(): void
Expand Down
Loading