diff --git a/CHANGELOG b/CHANGELOG index a266a8a49..443f4f010 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ * Stop reporting a skipped test in `IntegrationTestCase` when there is no legacy test to run * Make the `IntegrationTestCase` and `NodeTestCase` test helpers compatible with PHPUnit 11 * Cast printed expressions to string so values that cannot be converted to a string (arrays, non-`Stringable` objects, ...) report a usable stack trace at the print location + * Make the `include()` function return a `Markup` object so an assigned result is not re-escaped when printed * Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string # 3.27.1 (2026-05-30) diff --git a/doc/functions/include.rst b/doc/functions/include.rst index 3700018e5..dadac31cd 100644 --- a/doc/functions/include.rst +++ b/doc/functions/include.rst @@ -8,6 +8,18 @@ The ``include`` function returns the rendered content of a template: {{ include('template.html.twig') }} {{ include(some_var) }} +The returned content is a ``\Twig\Markup`` instance, so it is considered safe +and is not escaped again when you store it in a variable and print it later: + +.. code-block:: twig + + {% set body = include('body.html.twig') %} + {{ body }} {# rendered as-is, not re-escaped #} + +Beware that, like any safe value, it is not re-escaped for the context it ends +up in, so only embed it in the same context it was rendered for (typically +HTML). + Included templates have access to the variables of the active context. If you are using the filesystem loader, the templates are looked for in the diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index 092cd4777..a49f5795e 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -1490,9 +1490,11 @@ final class CoreExtension extends AbstractExtension * @param bool $ignoreMissing Whether to ignore missing templates or not * @param bool $sandboxed Whether to sandbox the template or not * + * @return string|Markup + * * @internal */ - public static function include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false): string + public static function include(Environment $env, $context, $template, $variables = [], $withContext = true, $ignoreMissing = false, $sandboxed = false) { $alreadySandboxed = false; $sandbox = null; @@ -1519,7 +1521,9 @@ final class CoreExtension extends AbstractExtension return ''; } - return $loaded->render($variables); + $rendered = $loaded->render($variables); + + return '' === $rendered ? '' : new Markup($rendered, $env->getCharset()); } finally { if ($isSandboxed && !$alreadySandboxed) { $sandbox->disableSandbox(); diff --git a/tests/Extension/CoreTest.php b/tests/Extension/CoreTest.php index ac8839369..faada348a 100644 --- a/tests/Extension/CoreTest.php +++ b/tests/Extension/CoreTest.php @@ -29,6 +29,7 @@ use Twig\Error\RuntimeError; use Twig\Extension\CoreExtension; use Twig\Extension\SandboxExtension; use Twig\Loader\ArrayLoader; +use Twig\Markup; use Twig\Sandbox\SecurityError; use Twig\Sandbox\SecurityPolicy; @@ -417,6 +418,41 @@ class CoreTest extends TestCase $twig->render('index'); } + public function testSandboxedIncludeResultStaysEscapedWhenAssigned() + { + $twig = new Environment(new ArrayLoader([ + 'index' => "{% set body = include('included', sandboxed: true) %}[{{ body }}]", + 'included' => '{{ evil }}', + ]), ['autoescape' => 'html']); + $twig->addExtension(new SandboxExtension(new SecurityPolicy([], ['escape'], [], [], ['include']), false)); + + $this->assertSame('[<script>]', $twig->render('index', ['evil' => '