Merge branch '3.x' into 4.x

* 3.x:
  Reject cross-environment template wrappers in block chains
  Reject cross-environment template wrappers

# Conflicts:
#	CHANGELOG
#	tests/TemplateTest.php
This commit is contained in:
Fabien Potencier
2026-09-14 14:13:36 +02:00
9 changed files with 85 additions and 20 deletions
+2 -7
View File
@@ -58,12 +58,11 @@ final class BlockChain
throw new \TypeError(\sprintf('Block chain templates must be strings, "%s" or "%s" instances, "%s" given.', TemplateWrapper::class, self::class, get_debug_type($template)));
}
$template = $template->unwrap();
if (!$template->isOwnedBy($env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
$this->templates[] = $template;
$this->templates[] = $template->unwrap($env);
}
if (!$this->templates) {
@@ -210,11 +209,7 @@ final class BlockChain
$parent = $template->getParent($context);
$fixed = $fixed && $template->hasFixedParent();
// a dynamic parent expression can evaluate to a template from another environment
$template = $parent instanceof TemplateWrapper ? $parent->unwrap() : $parent;
if (false !== $template && !$template->isOwnedBy($this->env)) {
throw new \LogicException('A block chain cannot contain templates from different Twig environments.');
}
$template = $parent;
} while (false !== $template);
}
+3 -1
View File
@@ -335,6 +335,8 @@ class Environment
public function load(string|TemplateWrapper $name): TemplateWrapper
{
if ($name instanceof TemplateWrapper) {
$name->unwrap($this);
return $name;
}
@@ -471,7 +473,7 @@ class Environment
$count = \count($names);
foreach ($names as $name) {
if ($name instanceof TemplateWrapper) {
return $name;
return $this->load($name);
}
if (1 !== $count && !$this->getLoader()->exists($name)) {
+8 -5
View File
@@ -80,9 +80,9 @@ abstract class Template
* This method is for internal use only and should never be called
* directly.
*
* @return self|TemplateWrapper|false The parent template or false if there is no parent
* @return self|false The parent template or false if there is no parent
*/
public function getParent(array $context): self|TemplateWrapper|false
public function getParent(array $context): self|false
{
if (null !== $this->parent) {
return $this->parent;
@@ -106,7 +106,10 @@ abstract class Template
return false;
}
if ($parent instanceof self || $parent instanceof TemplateWrapper) {
if ($parent instanceof TemplateWrapper) {
$parent = $this->load($parent, -1);
}
if ($parent instanceof self) {
return $this->parents[$parent->getSourceContext()->getName()] = $parent;
}
@@ -265,11 +268,11 @@ abstract class Template
{
try {
if (\is_array($template)) {
return $this->env->resolveTemplate($template)->unwrap();
return $this->env->resolveTemplate($template)->unwrap($this->env);
}
if ($template instanceof TemplateWrapper) {
return $template->unwrap();
return $template->unwrap($this->env);
}
if ($template === $this->getTemplateName()) {
+15 -3
View File
@@ -11,6 +11,8 @@
namespace Twig;
use Twig\Error\RuntimeError;
/**
* Exposes a template to userland.
*
@@ -96,11 +98,21 @@ final class TemplateWrapper
/**
* @internal
*
* @return Template
*/
public function unwrap()
public function isOwnedBy(Environment $env): bool
{
return $this->env === $env && $this->template->isOwnedBy($env);
}
/**
* @internal
*/
public function unwrap(Environment $env): Template
{
if (!$this->isOwnedBy($env)) {
throw new RuntimeError(\sprintf('A "%s" can only be used with the "%s" that created it.', self::class, Environment::class));
}
return $this->template;
}
}
+3 -3
View File
@@ -308,7 +308,7 @@ class BlockChainTest extends TestCase
{
$twig = new Environment(new ArrayLoader(['theme' => '']));
$other = new Environment(new ArrayLoader(['theme' => '']));
$wrapper = new TemplateWrapper($twig, $other->load('theme')->unwrap());
$wrapper = new TemplateWrapper($twig, $other->load('theme')->unwrap($other));
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
@@ -322,8 +322,8 @@ class BlockChainTest extends TestCase
$other = new Environment(new ArrayLoader(['parent' => '']));
$chain = new BlockChain($twig, ['theme'], ['parent' => $other->load('parent')]);
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('A block chain cannot contain templates from different Twig environments.');
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('A "Twig\TemplateWrapper" can only be used with the "Twig\Environment" that created it in "theme" at line 1.');
$chain->getBlockNames();
}
+1 -1
View File
@@ -179,6 +179,6 @@ class CallMacroTest extends TestCase
{
$twig = new Environment(new ArrayLoader($templates));
return $twig->load('index')->unwrap();
return $twig->load('index')->unwrap($twig);
}
}
+11
View File
@@ -81,6 +81,17 @@ class EnvironmentTest extends TestCase
$this->assertEquals('foo\u003Cbr\/\u0020\u003E foo\u003Cbr\/\u0020\u003E', $twig->render('js', ['bar' => 'foo<br/ >']));
}
public function testRejectsTemplateWrapperFromAnotherEnvironment(): void
{
$foreign = new Environment(new ArrayLoader(['index' => 'foreign']));
$twig = new Environment(new ArrayLoader());
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('can only be used with the "Twig\\Environment" that created it');
$twig->load($foreign->load('index'));
}
public function escapingStrategyCallback($name)
{
return $name;
+29
View File
@@ -11,6 +11,7 @@
namespace Twig\Tests\Sandbox;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Error\RuntimeError;
@@ -221,6 +222,34 @@ class SandboxTest extends TestCase
$sandbox->render('index');
}
/**
* @dataProvider provideForeignTemplateWrapperUsages
*/
#[DataProvider('provideForeignTemplateWrapperUsages')]
public function testRejectsTemplateWrapperFromAnotherEnvironment(string $template, string $foreignTemplate, array $tags = [], array $functions = []): void
{
$foreign = self::env(['foreign' => $foreignTemplate]);
$sandbox = new Sandbox(self::env(['index' => $template]), self::strictPolicy(tags: $tags, functions: $functions));
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('can only be used with the "Twig\\Environment" that created it');
$sandbox->render('index', ['foreign' => $foreign->load('foreign')]);
}
public static function provideForeignTemplateWrapperUsages(): iterable
{
yield 'include tag' => ['{% include foreign %}', 'foreign content', ['include']];
yield 'include function' => ['{{ include(foreign) }}', 'foreign content', [], ['include']];
yield 'include function fallback' => ['{{ include(["missing", foreign]) }}', 'foreign content', [], ['include']];
yield 'include_only function' => ['{{ include_only(foreign) }}', 'foreign content', [], ['include_only']];
yield 'extends tag' => ['{% extends foreign %}', 'foreign content', ['extends']];
yield 'embed tag' => ['{% embed foreign %}{% endembed %}', 'foreign content', ['embed', 'extends']];
yield 'import tag' => ['{% import foreign as macros %}{{ macros.foo() }}', '{% macro foo() %}foreign content{% endmacro %}', ['import']];
yield 'from tag' => ['{% from foreign import foo %}{{ foo() }}', '{% macro foo() %}foreign content{% endmacro %}', ['from']];
yield 'block function' => ['{{ block("content", foreign) }}', '{% block content %}foreign content{% endblock %}', [], ['block']];
}
public function testTheExtendsTagMustBeAllowed(): void
{
$templates = [
+13
View File
@@ -26,10 +26,23 @@ use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Loader\ArrayLoader;
use Twig\Template;
use Twig\TwigFunction;
class TemplateWrapperTest extends TestCase
{
public function testUnwrapChecksTheEnvironment(): void
{
$twig = new Environment(new ArrayLoader(['index' => 'content']));
$wrapper = $twig->load('index');
$this->assertInstanceOf(Template::class, $wrapper->unwrap($twig));
$this->expectException(RuntimeError::class);
$this->expectExceptionMessage('can only be used with the "Twig\\Environment" that created it');
$wrapper->unwrap(new Environment(new ArrayLoader()));
}
public function testHasGetBlocks(): void
{
$twig = new Environment(new ArrayLoader([