Allow marking a Markup instance as safe for some escaping strategies only

This commit is contained in:
Fabien Potencier
2026-09-18 23:06:33 +01:00
parent 11417e29b3
commit 527710bd71
10 changed files with 203 additions and 11 deletions
+1
View File
@@ -15,6 +15,7 @@
* Speed up loading compiled templates that OPcache already holds
* Speed up applying `html_cva()` recipes that resolve to classes already seen
* Fix a class marked safe for `html_attr` not being considered safe in `html` and `html_attr_relaxed` contexts
* Add a way to mark a `Twig\Markup` instance as safe for some escaping strategies only
# 3.30.0 (2026-09-25)
+24
View File
@@ -558,6 +558,30 @@ The escaping rules are implemented as follows:
and ``html``, and a class marked safe for ``html_attr_relaxed`` is also safe
for ``html``, as the stricter escaping covers the looser contexts.
* A single value can be marked as safe for some strategies when it is created,
without registering its class:
.. versionadded:: 3.31
The third argument of the ``Twig\Markup`` constructor was added in Twig
3.31.
.. code-block:: php
// safe for the HTML strategy only, escaped everywhere else
$value = new \Twig\Markup($html, 'UTF-8', ['html']);
// safe for the HTML and JS strategies
$value = new \Twig\Markup($html, 'UTF-8', ['html', 'js']);
// safe for all strategies, the default
$value = new \Twig\Markup($html, 'UTF-8');
Mark values for the strategies they were really produced for: a value built
for an HTML page is not safe to inject into a JavaScript or CSS context. The
strategies of the instance always win over the ones registered for its class,
and an explicit ``|escape('html')`` in a template escapes the value anyway.
* Escaping is applied before printing, after any other filter is applied:
.. code-block:: twig
+2 -2
View File
@@ -1218,8 +1218,8 @@ final class CoreExtension extends AbstractExtension
default => throw new RuntimeError('Trimming side must be "left", "right" or "both".'),
};
// trimming a safe string with the default character mask always returns a safe string (independently of the context)
return $string instanceof Markup && self::DEFAULT_TRIM_CHARS === $characterMask ? new Markup($trimmed, $string->getCharset()) : $trimmed;
// trimming a safe string with the default character mask keeps it as safe as it was
return $string instanceof Markup && self::DEFAULT_TRIM_CHARS === $characterMask ? $string->withContent($trimmed) : $trimmed;
}
/**
+36 -5
View File
@@ -11,13 +11,16 @@
namespace Twig;
use Twig\Runtime\EscaperRuntime;
/**
* Marks a content as safe.
*
* Instances of this class (and existing subclasses) are trusted by the Twig
* sandbox: method calls and property accesses on a Markup instance bypass the
* SecurityPolicy method/property allowlists. This is by design: Markup
* represents content that has already been deemed safe to output.
* sandbox: method calls (except withContent()) and property accesses on a
* Markup instance bypass the SecurityPolicy method/property allowlists. This
* is by design: Markup represents content that has already been deemed safe
* to output.
*
* This class is considered final as of Twig 3.28 and will be final in Twig
* 4.0.
@@ -31,8 +34,14 @@ class Markup implements \Countable, \JsonSerializable, \Stringable
private $content;
private ?string $charset;
public function __construct($content, $charset)
{
/**
* @param string[] $safeStrategies The escaping strategies the content is safe for, `['all']` for all of them
*/
public function __construct(
$content,
$charset,
private array $safeStrategies = ['all'],
) {
$this->content = (string) $content;
$this->charset = $charset;
}
@@ -47,6 +56,28 @@ class Markup implements \Countable, \JsonSerializable, \Stringable
return $this->charset;
}
/**
* @internal
*/
public function withContent(string $content): self
{
$markup = clone $this;
$markup->content = $content;
return $markup;
}
public function isSafeFor(string $strategy): bool
{
foreach ($this->safeStrategies as $declared) {
if ('all' === $declared || $strategy === $declared || \in_array($strategy, EscaperRuntime::IMPLIED_STRATEGIES[$declared] ?? [], true)) {
return true;
}
}
return false;
}
/**
* @return int
*/
+4 -2
View File
@@ -21,8 +21,10 @@ final class EscaperRuntime implements RuntimeExtensionInterface
* Strategies a value is also safe for when it is safe for the declared one.
*
* Mirrors the compile-time rules of Twig\NodeVisitor\SafeAnalysisNodeVisitor.
*
* @internal
*/
private const IMPLIED_STRATEGIES = [
public const IMPLIED_STRATEGIES = [
'html_attr' => ['html_attr_relaxed', 'html'],
'html_attr_relaxed' => ['html'],
];
@@ -116,7 +118,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
public function escape($string, string $strategy = 'html', ?string $charset = null, bool $autoescape = false)
{
if ($autoescape && $string instanceof Markup) {
return $string;
return $string->isSafeFor($strategy) ? $string : $this->escape((string) $string, $strategy, $charset);
}
if (!\is_string($string)) {
+3 -2
View File
@@ -146,12 +146,13 @@ final class SecurityPolicy implements SecurityPolicyInterface
public function checkMethodAllowed($obj, $method): void
{
if ($obj instanceof Template || $obj instanceof Markup) {
$method = strtolower($method);
if ($obj instanceof Template || ($obj instanceof Markup && 'withcontent' !== $method)) {
return;
}
$allowed = false;
$method = strtolower($method);
foreach ($this->allowedMethods as $class => $methods) {
if ($obj instanceof $class && \in_array($method, $methods, true)) {
$allowed = true;
@@ -0,0 +1,22 @@
--TEST--
A "Markup" instance is only safe for the strategies it was created for
--TEMPLATE--
{{ html }}
{{ html|trim }}
{{ safe_everywhere }}
--DATA--
return ['html' => new \Twig\Markup(' <br /> ', 'UTF-8', ['html']), 'safe_everywhere' => new \Twig\Markup('<br />', 'UTF-8')]
--CONFIG--
return ['autoescape' => 'html']
--EXPECT--
<br />
<br />
<br />
--DATA--
return ['html' => new \Twig\Markup(' <br /> ', 'UTF-8', ['html']), 'safe_everywhere' => new \Twig\Markup('<br />', 'UTF-8')]
--CONFIG--
return ['autoescape' => 'js']
--EXPECT--
\u0020\u003Cbr\u0020\/\u003E\u0020
\u003Cbr\u0020\/\u003E
<br />
+53
View File
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Markup;
class MarkupTest extends TestCase
{
public function testContentIsSafeForAllStrategiesByDefault(): void
{
$this->assertTrue((new Markup('<br />', 'UTF-8'))->isSafeFor('js'));
}
/**
* @dataProvider provideSafeStrategies
*/
#[DataProvider('provideSafeStrategies')]
public function testContentIsSafeForTheGivenStrategies(bool $safe, array $safeStrategies, string $strategy): void
{
$this->assertSame($safe, (new Markup('<br />', 'UTF-8', $safeStrategies))->isSafeFor($strategy));
}
public static function provideSafeStrategies(): iterable
{
yield [true, ['all'], 'js'];
yield [true, ['html', 'js'], 'js'];
yield [false, ['html', 'js'], 'css'];
yield [true, ['html_attr'], 'html'];
yield [true, ['html_attr'], 'html_attr_relaxed'];
yield [false, ['html'], 'html_attr'];
yield [false, [], 'html'];
}
public function testWithContentKeepsTheSafeStrategies(): void
{
$markup = (new Markup('<br />', 'UTF-8', ['html']))->withContent('<hr />');
$this->assertSame('<hr />', (string) $markup);
$this->assertTrue($markup->isSafeFor('html'));
$this->assertFalse($markup->isSafeFor('js'));
}
}
+38
View File
@@ -23,6 +23,7 @@ namespace Twig\Tests\Runtime;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Twig\Error\RuntimeError;
use Twig\Markup;
use Twig\Runtime\EscaperRuntime;
class EscaperRuntimeTest extends TestCase
@@ -461,6 +462,43 @@ class EscaperRuntimeTest extends TestCase
$this->assertSame('<br />', $escaper->escape(new Extension_TestClass(), 'html', null, true));
}
/**
* @dataProvider provideMarkupSafeStrategies
*/
#[DataProvider('provideMarkupSafeStrategies')]
public function testMarkupIsOnlySafeForItsOwnStrategies(string $expected, ?array $safeStrategies, string $strategy): void
{
$markup = null === $safeStrategies ? new Markup('<br />', 'UTF-8') : new Markup('<br />', 'UTF-8', $safeStrategies);
$this->assertSame($expected, (string) (new EscaperRuntime())->escape($markup, $strategy, null, true));
}
public static function provideMarkupSafeStrategies()
{
return [
'no argument stays safe everywhere' => ['<br />', null, 'js'],
'all' => ['<br />', ['all'], 'js'],
'current strategy' => ['<br />', ['html'], 'html'],
'other strategy' => ['\u003Cbr\u0020\/\u003E', ['html'], 'js'],
'several strategies' => ['<br />', ['html', 'js'], 'js'],
'implied strategy' => ['<br />', ['html_attr'], 'html'],
'no strategy' => ['&lt;br /&gt;', [], 'html'],
];
}
public function testMarkupStrategiesTakePrecedenceOverSafeClasses(): void
{
$escaper = new EscaperRuntime();
$escaper->addSafeClass(Markup::class, ['html']);
$this->assertSame('&lt;br /&gt;', $escaper->escape(new Markup('<br />', 'UTF-8', ['js']), 'html', null, true));
}
public function testMarkupIsEscapedWhenTheDeveloperCallsTheEscapeFilter(): void
{
$this->assertSame('&lt;br /&gt;', (new EscaperRuntime())->escape(new Markup('<br />', 'UTF-8', ['html']), 'html'));
}
}
function escaper($string, $charset)
+20
View File
@@ -218,6 +218,26 @@ class SandboxTest extends TestCase
$this->assertSame('<b>b</b>', $sandbox->render('index', ['m' => new Markup('<b>b</b>', 'UTF-8')]));
}
public function testMarkupSafeStrategiesCanBeChecked(): void
{
$sandbox = new Sandbox(self::env(['index' => '{{ m.isSafeFor("html") ? "yes" : "no" }}']), self::strictPolicy());
$this->assertSame('yes', $sandbox->render('index', ['m' => new Markup('b', 'UTF-8', ['html'])]));
}
public function testMarkupWithContentIsGovernedByThePolicy(): void
{
$templates = ['index' => '{{ m.withContent("<b>b</b>") }}'];
$allowing = new Sandbox(self::env($templates), self::strictPolicy(methods: [Markup::class => ['withContent']]));
$this->assertSame('<b>b</b>', $allowing->render('index', ['m' => new Markup('a', 'UTF-8')]));
$denying = new Sandbox(self::env($templates), self::strictPolicy());
$this->expectException(SecurityNotAllowedMethodError::class);
$this->expectExceptionMessage('Calling "withcontent" method on a "Twig\Markup" object is not allowed');
$denying->render('index', ['m' => new Markup('a', 'UTF-8')]);
}
public function testIncludedTemplatesAreSandboxed(): void
{
$sandbox = new Sandbox(self::env([