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
13 changes: 11 additions & 2 deletions src/Twig/Extension/UtilsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use function Cake\Core\deprecationWarning;

/**
* Class UtilsExtension.
Expand All @@ -34,8 +35,16 @@ class UtilsExtension extends AbstractExtension
public function getFilters(): array
{
return [
new TwigFilter('serialize', 'serialize'),
new TwigFilter('unserialize', 'unserialize'),
new TwigFilter('serialize', function (string $value): mixed {
deprecationWarning('5.0.2', 'Usage of serialize in templates deprecated.');

return serialize($value);
}),
new TwigFilter('unserialize', function (string $value): mixed {
deprecationWarning('5.0.2', 'unserialize is deprecated. Its usage creates arbitrary object deserialization issues');

return unserialize($value, ['allowed_classes' => false]);
}),
new TwigFilter('md5', 'md5'),
new TwigFilter('base64_encode', 'base64_encode'),
new TwigFilter('base64_decode', 'base64_decode'),
Expand Down
39 changes: 34 additions & 5 deletions src/Twig/FileLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,26 @@ public function exists(string $name)
*/
public function findTemplate(string $name): string
{
$templatePaths = App::path('templates');
if (file_exists($name)) {
return $name;
$name = str_replace('//', '/', $name);
// Check both app template paths and all plugins,
// as template from element() end up here too.
// We also need to protect against `{% include var_name %}`
// where var_name is request data.
foreach ($templatePaths as $templatePath) {
if (str_starts_with($name, $templatePath)) {
return $name;
}
}
foreach (Plugin::loaded() as $pluginName) {
$pluginPath = Plugin::templatePath($pluginName);
if (str_starts_with($name, $pluginPath)) {
return $name;
}
}

throw $this->loaderError($name, $templatePaths);
}

[$plugin, $name] = pluginSplit($name);
Expand All @@ -105,24 +123,35 @@ public function findTemplate(string $name): string
if ($path !== null) {
return $path;
}

$error = "Could not find template `{$name}` in plugin `{$plugin}` in these paths:\n\n"
. "- `{$templatePath}`\n";
throw new LoaderError($error);
}

foreach (App::path('templates') as $templatePath) {
foreach ($templatePaths as $templatePath) {
$path = $this->checkExtensions($templatePath . $name);
if ($path !== null) {
return $path;
}
}
throw $this->loaderError($name, $templatePaths);
}

/**
* Create a LoaderError with template path list in the message.
*
* @param string $name The name of the template that could not be found.
* @param array $templatePaths List of template paths that were searched
* @return \Twig\Error\LoaderError
*/
protected function loaderError(string $name, array $templatePaths): LoaderError
{
$error = "Could not find template `{$name}` in these paths:\n\n";
foreach (App::path('templates') as $templatePath) {
foreach ($templatePaths as $templatePath) {
$error .= "- `{$templatePath}`\n";
}
throw new LoaderError($error);

return new LoaderError($error);
}

/**
Expand Down
55 changes: 55 additions & 0 deletions tests/TestCase/Twig/Extension/UtilsExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);

/**
* CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
* Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* Copyright (c) 2014 Cees-Jan Kiewiet
*
* Licensed under The MIT License
* For full copyright and license information, please see the LICENSE.txt
* Redistributions of files must retain the above copyright notice
*
* @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
* @link https://cakephp.org CakePHP(tm) Project
* @since 1.0.0
* @license https://opensource.org/licenses/mit-license.php MIT License
*/

namespace Cake\TwigView\Test\TestCase\Twig\Extension;

use Cake\TwigView\Twig\Extension\UtilsExtension;
use TestApp\GadgetMarker;
use Twig\Environment;
use Twig\Loader\ArrayLoader;

class UtilsExtensionTest extends AbstractExtensionTest
{
protected function setUp(): void
{
parent::setUp();
$this->extension = new UtilsExtension();
}

public function testUnserializePreventObject(): void
{
$this->skipIf(PHP_VERSION_ID < 80300, 'Requires PHP8.3 or higher');

$twig = new Environment(new ArrayLoader([
// {% set %} so we exercise the filter without stringifying the result.
'object' => '{% set _ = payload|unserialize %}(rendered)',
'array' => '{{ (payload|unserialize)["role"] }}',
]));
$twig->addExtension(new UtilsExtension());

// 1) Object payload: does a gadget's magic method run?
GadgetMarker::$woken = false;
$this->deprecated(function () use ($twig): void {
$twig->render('object', ['payload' => serialize(new GadgetMarker())]);
$this->assertFalse(GadgetMarker::$woken, 'Should not have modified GadgetMarker');

$out = $twig->render('array', ['payload' => serialize(['role' => 'editor'])]);
$this->assertStringContainsString('editor', $out);
});
}
}
11 changes: 6 additions & 5 deletions tests/TestCase/Twig/FileLoaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,14 @@ public function testGetCacheKeyPluginNonExistingFile()

public function testIsFresh()
{
file_put_contents(TMP . 'TwigViewIsFreshTest', 'TwigViewIsFreshTest');
$time = filemtime(TMP . 'TwigViewIsFreshTest');
$path = TEST_APP . 'templates/test_is_fresh.twig';
file_put_contents($path, 'is fresh test');
$time = filemtime($path);

$this->assertTrue($this->loader->isFresh(TMP . 'TwigViewIsFreshTest', $time + 5));
$this->assertTrue(!$this->loader->isFresh(TMP . 'TwigViewIsFreshTest', $time - 5));
$this->assertTrue($this->loader->isFresh($path, $time + 5));
$this->assertTrue(!$this->loader->isFresh($path, $time - 5));

unlink(TMP . 'TwigViewIsFreshTest');
unlink($path);
}

public function testIsFreshNonExistingFile()
Expand Down
12 changes: 12 additions & 0 deletions tests/TestCase/View/TwigViewTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

use Cake\TestSuite\TestCase;
use TestApp\View\AppView;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Extra\Markdown\DefaultMarkdown;
Expand Down Expand Up @@ -242,6 +243,17 @@ public function testThrowSyntaxError()
$this->view->render('syntaxerror', false);
}

public function testTemplatePathRestriction()
{
$path = TMP . 'secret.txt';
file_put_contents($path, 'SECRET DATA from /tmp/secret.txt');
$view = new AppView();
$view->set('item', $path);
$this->expectException(LoaderError::class);
$this->expectExceptionMessage('Could not find template');
$view->render('template_path_restriction');
}

public function testHelperFunction()
{
$view = new AppView(null, null, null, [
Expand Down
15 changes: 15 additions & 0 deletions tests/test_app/src/GadgetMarker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);

namespace TestApp;

class GadgetMarker
{
public static bool $woken = false;

public function __wakeup(): void
{
self::$woken = true;
echo " [!] GadgetMarker::__wakeup fired during unserialize (object injection)\n";
}
}
5 changes: 5 additions & 0 deletions tests/test_app/templates/template_path_restriction.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{#
Ensure that `include` applies template path restriction, so that user data + include don't open
up arbitrary file reads.
#}
{% include item %}