Skip to content

Commit 06f10a7

Browse files
authored
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
2 parents 24640dc + 9491b93 commit 06f10a7

4 files changed

Lines changed: 185 additions & 1 deletion

File tree

‎flight/Engine.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ public function init(): void
188188
$this->loader->register('view', View::class, [], function (View $view) use ($self) {
189189
$view->path = $self->get('flight.views.path');
190190
$view->extension = $self->get('flight.views.extension');
191+
$view->restrictToPath = (bool) $self->get('flight.views.restrict_to_path');
191192
});
192193

193194
foreach (self::MAPPABLE_METHODS as $name) {
@@ -202,6 +203,7 @@ public function init(): void
202203
$this->set('flight.debug', false);
203204
$this->set('flight.views.path', './views');
204205
$this->set('flight.views.extension', '.php');
206+
$this->set('flight.views.restrict_to_path', false);
205207
$this->set('flight.content_length', true);
206208
$this->set('flight.v2.output_buffering', false);
207209
$this->set('flight.allow_method_override', true);

‎flight/template/View.php‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ class View
2222

2323
public bool $preserveVars = true;
2424

25+
/**
26+
* When true, render(), fetch() and exists() only accept template files
27+
* that resolve inside $path. Off by default so existing behavior holds.
28+
* Prefer Flight::set('flight.views.restrict_to_path', true); Engine applies it.
29+
*/
30+
public bool $restrictToPath = false;
31+
2532
/**
2633
* View variables.
2734
*
@@ -116,6 +123,10 @@ public function render(string $file, ?array $templateData = null): void
116123
throw new \Exception("Template file not found: {$normalized_path}.");
117124
}
118125

126+
if (!$this->isInsidePath($this->template)) {
127+
throw new \Exception('Template file is outside the views path.');
128+
}
129+
119130
\extract($this->vars);
120131

121132
if (\is_array($templateData) === true) {
@@ -155,7 +166,9 @@ public function fetch(string $file, ?array $data = null): string
155166
*/
156167
public function exists(string $file): bool
157168
{
158-
return \file_exists($this->getTemplate($file));
169+
$template = $this->getTemplate($file);
170+
171+
return \file_exists($template) && $this->isInsidePath($template);
159172
}
160173

161174
/**
@@ -196,6 +209,29 @@ public function e(string $str): string
196209
return $value;
197210
}
198211

212+
/**
213+
* Checks that an existing template file resolves inside the views path.
214+
* Always true while $restrictToPath is off. Fails closed when either
215+
* path cannot be resolved.
216+
*/
217+
protected function isInsidePath(string $template): bool
218+
{
219+
if ($this->restrictToPath === false) {
220+
return true;
221+
}
222+
223+
$root = \realpath($this->path);
224+
$resolved = \realpath($template);
225+
226+
if ($root === false || $resolved === false) {
227+
return false;
228+
}
229+
230+
$root = \rtrim($root, '\\/') . DIRECTORY_SEPARATOR;
231+
232+
return \strpos($resolved, $root) === 0;
233+
}
234+
199235
protected static function normalizePath(string $path, string $separator = DIRECTORY_SEPARATOR): string
200236
{
201237
return \str_replace(['\\', '/'], $separator, $path);

‎tests/EngineTest.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,4 +1380,15 @@ public function testAllowMethodOverrideFalseDoesNotClobberManualMethodWithoutOve
13801380

13811381
$this->assertSame('HEAD', $engine->request()->method);
13821382
}
1383+
1384+
public function testViewsRestrictToPathConfigIsAppliedToView(): void
1385+
{
1386+
$engine = new Engine();
1387+
$this->assertFalse($engine->view()->restrictToPath);
1388+
1389+
$engine = new Engine();
1390+
$engine->set('flight.views.restrict_to_path', true);
1391+
$this->assertTrue($engine->view()->restrictToPath);
1392+
}
1393+
13831394
}

‎tests/ViewTest.php‎

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,4 +255,139 @@ public static function renderDataProvider(): array
255255
],
256256
];
257257
}
258+
259+
public function testAbsolutePathStillRendersByDefault(): void
260+
{
261+
$file = $this->makeOutsideTemplate('outside');
262+
263+
try {
264+
$this->expectOutputString('outside');
265+
$this->view->render($file);
266+
$this->assertTrue($this->view->exists($file));
267+
} finally {
268+
$this->removeDir(dirname($file));
269+
}
270+
}
271+
272+
public function testRestrictToPathIsOffByDefault(): void
273+
{
274+
$this->assertFalse((new View())->restrictToPath);
275+
}
276+
277+
public function testRestrictToPathRendersViewInsidePath(): void
278+
{
279+
$this->view->restrictToPath = true;
280+
281+
$this->expectOutputString('Hello, Bob!');
282+
$this->view->render('hello', ['name' => 'Bob']);
283+
$this->assertTrue($this->view->exists('hello'));
284+
$this->assertTrue($this->view->exists('layouts/layout'));
285+
}
286+
287+
public function testRestrictToPathAllowsAbsolutePathInsidePath(): void
288+
{
289+
$this->view->restrictToPath = true;
290+
$file = (string) realpath(__DIR__ . '/views/hello.php');
291+
292+
$this->expectOutputString('Hello, Bob!');
293+
$this->view->render($file, ['name' => 'Bob']);
294+
}
295+
296+
public function testRestrictToPathRejectsRelativePathOutsidePath(): void
297+
{
298+
$this->view->restrictToPath = true;
299+
$dir = __DIR__ . DIRECTORY_SEPARATOR . 'restrict-' . uniqid();
300+
mkdir($dir);
301+
file_put_contents($dir . DIRECTORY_SEPARATOR . 'note.php', 'outside');
302+
$name = '..' . DIRECTORY_SEPARATOR . basename($dir) . DIRECTORY_SEPARATOR . 'note';
303+
304+
try {
305+
$this->assertFalse($this->view->exists($name));
306+
$this->expectException(Exception::class);
307+
$this->expectExceptionMessage('Template file is outside the views path.');
308+
$this->view->render($name);
309+
} finally {
310+
$this->removeDir($dir);
311+
}
312+
}
313+
314+
public function testRestrictToPathRejectsAbsolutePathOutsidePath(): void
315+
{
316+
$this->view->restrictToPath = true;
317+
$file = $this->makeOutsideTemplate('outside');
318+
319+
try {
320+
$this->assertFalse($this->view->exists($file));
321+
$this->expectException(Exception::class);
322+
$this->expectExceptionMessage('Template file is outside the views path.');
323+
$this->view->render($file);
324+
} finally {
325+
$this->removeDir(dirname($file));
326+
}
327+
}
328+
329+
public function testRestrictToPathRejectsSymlinkOutsidePath(): void
330+
{
331+
$root = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-view-' . uniqid();
332+
mkdir($root . DIRECTORY_SEPARATOR . 'views', 0777, true);
333+
mkdir($root . DIRECTORY_SEPARATOR . 'outside');
334+
$target = $root . DIRECTORY_SEPARATOR . 'outside' . DIRECTORY_SEPARATOR . 'note.php';
335+
file_put_contents($target, 'outside');
336+
337+
if (!@symlink($target, $root . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR . 'alias.php')) {
338+
$this->removeDir($root);
339+
$this->markTestSkipped('Symlinks are not available.');
340+
}
341+
342+
$view = new View($root . DIRECTORY_SEPARATOR . 'views');
343+
$view->restrictToPath = true;
344+
345+
try {
346+
$this->assertFalse($view->exists('alias'));
347+
$this->expectException(Exception::class);
348+
$this->expectExceptionMessage('Template file is outside the views path.');
349+
$view->render('alias');
350+
} finally {
351+
$this->removeDir($root);
352+
}
353+
}
354+
355+
public function testRestrictToPathKeepsNotFoundMessage(): void
356+
{
357+
$this->view->restrictToPath = true;
358+
359+
$this->assertFalse($this->view->exists('badfile'));
360+
$this->expectException(Exception::class);
361+
$this->expectExceptionMessage('Template file not found:');
362+
$this->view->render('badfile');
363+
}
364+
365+
private function makeOutsideTemplate(string $content): string
366+
{
367+
$dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'flight-view-' . uniqid();
368+
mkdir($dir);
369+
$file = $dir . DIRECTORY_SEPARATOR . 'note.php';
370+
file_put_contents($file, $content);
371+
372+
return $file;
373+
}
374+
375+
private function removeDir(string $dir): void
376+
{
377+
foreach ((array) scandir($dir) as $item) {
378+
if ($item === '.' || $item === '..' || $item === false) {
379+
continue;
380+
}
381+
382+
$path = $dir . DIRECTORY_SEPARATOR . $item;
383+
384+
if (is_dir($path) && !is_link($path)) {
385+
$this->removeDir($path);
386+
} else {
387+
unlink($path);
388+
}
389+
}
390+
391+
rmdir($dir);
392+
}
258393
}

0 commit comments

Comments
 (0)