Skip to content

Commit 04ace9d

Browse files
committed
Merge branch '3.x' into 4.x
* 3.x: Handle single-node child template bodies in cleanup Keep captured block definitions supported Clarify captured block deprecation wording Simplify correctness visitor checks Move extends validation into correctness visitor Fix correctness visitor regressions Fix test assertions that did not verify the intended behavior Address review: fix block-nesting checks in CorrectnessNodeVisitor Move the extends-in-block and extends-in-macro errors into the CorrectnessNodeVisitor Introduce a CorrectnessNodeVisitor to validate that templates are semantically correct Mark Markup as final Allow calling a macro with a dynamic name via the dot operator Add an allow-list for tests to the sandbox security policy Fix markdown_to_html mangling content that starts with a blank line # Conflicts: # CHANGELOG # doc/deprecated.rst # doc/sandbox.rst # src/ExpressionParser/Infix/DotExpressionParser.php # src/Extension/CoreExtension.php # src/Markup.php # src/Node/CheckSecurityNode.php # src/Node/Expression/Filter/DefaultFilter.php # src/Node/Expression/NullCoalesceExpression.php # src/Node/IfNode.php # src/NodeVisitor/SandboxNodeVisitor.php # src/Parser.php # src/Sandbox/SecurityPolicy.php # tests/Extension/SandboxTest.php # tests/ParserTest.php
2 parents 5c0a528 + 7f9714a commit 04ace9d

55 files changed

Lines changed: 1560 additions & 217 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.php-cs-fixer.dist.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
'phpdoc_to_comment' => ['ignored_tags' => ['var']],
1919
'ordered_imports' => true,
2020
'phpdoc_types_order' => ['null_adjustment' => 'always_last', 'sort_algorithm' => 'none'],
21-
'no_superfluous_phpdoc_tags' => ['allow_mixed' => true],
21+
'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true],
2222
])
2323
->setRuleCustomisationPolicy(new class implements PhpCsFixer\Config\RuleCustomisationPolicyInterface {
2424
public function getPolicyVersionForCache(): string

doc/sandbox.rst

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,24 @@ properties and methods on objects::
2929
'Article' => ['title', 'body'],
3030
];
3131
$functions = ['range'];
32-
$policy = new \Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions);
32+
$tests = ['my_test'];
33+
$policy = new \Twig\Sandbox\SecurityPolicy($tags, $filters, $methods, $properties, $functions, $tests);
3334

3435
With the above configuration, the security policy will only allow usage of the
35-
``if`` tag, and the ``upper`` filter. Moreover, the templates will only be able
36-
to call the ``getTitle()`` and ``getBody()`` methods on ``Article`` objects,
37-
and the ``title`` and ``body`` public properties. Everything else won't be
38-
allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception.
36+
``if`` tag, the ``upper`` filter, and the ``my_test`` test (on top of the
37+
built-in tests that are always allowed, see below). Moreover, the templates
38+
will only be able to call the ``getTitle()`` and ``getBody()`` methods on
39+
``Article`` objects, and the ``title`` and ``body`` public properties.
40+
Everything else won't be allowed and will generate a
41+
``\Twig\Sandbox\SecurityError`` exception.
42+
43+
.. note::
44+
45+
The ``allowedTests`` argument is available since Twig 3.28 (in earlier
46+
versions all tests were always allowed). Most built-in tests (``empty``,
47+
``defined``, ``even``, ``same as``, ``iterable``, etc.) are always allowed
48+
and do not need to be listed. Only custom tests and the built-in
49+
``constant`` test must be allow-listed like filters and functions.
3950

4051
.. note::
4152

@@ -46,13 +57,13 @@ allowed and will generate a ``\Twig\Sandbox\SecurityError`` exception.
4657
Note that native array-like classes (like ``ArrayObject``) are always
4758
allowed, you don't need to configure them.
4859

49-
Marking Filters, Functions, and Tags as Always Allowed
50-
------------------------------------------------------
60+
Marking Filters, Functions, Tests, and Tags as Always Allowed
61+
-------------------------------------------------------------
5162

52-
Some filters, functions, and tags are inherently safe and should always be
53-
usable in sandboxed templates without forcing every policy to allow-list them.
54-
Mark such callables by setting the ``always_allowed_in_sandbox`` option to
55-
``true``::
63+
Some filters, functions, tests, and tags are inherently safe and should always
64+
be usable in sandboxed templates without forcing every policy to allow-list
65+
them. Mark such callables by setting the ``always_allowed_in_sandbox`` option
66+
to ``true``::
5667

5768
$twig->addFilter(new \Twig\TwigFilter('upper', 'strtoupper', [
5869
'always_allowed_in_sandbox' => true,
@@ -62,6 +73,10 @@ Mark such callables by setting the ``always_allowed_in_sandbox`` option to
6273
'always_allowed_in_sandbox' => true,
6374
]));
6475

76+
$twig->addTest(new \Twig\TwigTest('even', null, [
77+
'always_allowed_in_sandbox' => true,
78+
]));
79+
6580
For tags, override ``isAlwaysAllowedInSandbox()`` on your token parser to
6681
return ``true``::
6782

@@ -75,8 +90,8 @@ return ``true``::
7590
// ...
7691
}
7792

78-
Marked filters, functions, and tags are skipped by the sandbox security check
79-
entirely, so they incur no runtime overhead, and they do not need to be
93+
Marked filters, functions, tests, and tags are skipped by the sandbox security
94+
check entirely, so they incur no runtime overhead, and they do not need to be
8095
listed in the ``SecurityPolicy`` allow-lists.
8196

8297
The sandbox assumes that attackers control template source, not the Twig
@@ -93,9 +108,9 @@ Only mark a callable or tag as always allowed when **all** the following
93108
conditions hold:
94109

95110
* **No new capability.** The item must not expose anything beyond what the
96-
sandbox already accepts. Pure value predicates (``is even``), pure value
97-
transformations (``upper``, ``trim``, ``abs``), and pure control flow
98-
(``if``, ``for``, ``set``) qualify.
111+
sandbox already accepts. Pure value predicates (``is even``, ``is empty``),
112+
pure value transformations (``upper``, ``trim``, ``abs``), and pure control
113+
flow (``if``, ``for``, ``set``) qualify.
99114
* **No PHP runtime access.** The item must not read arbitrary PHP constants,
100115
call arbitrary classes or functions, instantiate objects from
101116
user-controlled names, or otherwise reach into the PHP runtime. This rules
@@ -154,6 +169,15 @@ When upgrading to 4.0, you can drop these names from your ``SecurityPolicy``
154169
allow-lists. Leaving them in is harmless: listing a name that is always
155170
allowed has no effect.
156171

172+
The corresponding built-in tests (``defined``, ``divisible by``, ``empty``,
173+
``even``, ``iterable``, ``mapping``, ``none``, ``null``, ``odd``, ``same as``,
174+
``sequence``, ``true``) are **already** flagged as always allowed since Twig
175+
3.28, so they never need to be allow-listed. This is safe because tests were
176+
never enforced by the sandbox before 3.28: flagging them keeps existing
177+
templates working unchanged. The ``constant`` test is the exception: it reaches
178+
into the PHP runtime, so it is not always allowed and must be allow-listed (it
179+
is still implicitly allowed in 3.x with a deprecation, and rejected in 4.0).
180+
157181
Enabling the Sandbox
158182
--------------------
159183

doc/tags/macro.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ The macros can then be called at will in the *current* template:
6464
{# You can also use named arguments #}
6565
<p>{{ forms.input(name: 'password', type: 'password') }}</p>
6666

67+
The macro name can also be dynamic by wrapping an expression with parenthesis
68+
after the :ref:`dot operator <dot_operator>`:
69+
70+
.. code-block:: html+twig
71+
72+
{% set field = 'input' %}
73+
<p>{{ forms.(field)('username') }}</p>
74+
<p>{{ forms.('text' ~ 'area')('comment') }}</p>
75+
76+
.. versionadded:: 3.28
77+
78+
Support for calling a macro with a dynamic name was added in Twig 3.28.
79+
6780
Alternatively you can import names from the template into the current namespace
6881
via the ``from`` tag:
6982

extra/markdown-extra/MarkdownRuntime.php

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,55 @@ public function __construct(MarkdownInterface $converter)
2222

2323
public function convert(string $body): string
2424
{
25-
// remove indentation
26-
if ($white = substr($body, 0, strspn($body, " \t\r\n\0\x0B"))) {
27-
$body = preg_replace("{^$white}m", '', $body);
25+
return $this->converter->convert(self::stripCommonIndentation($body));
26+
}
27+
28+
/**
29+
* Removes the indentation shared by all non-blank lines.
30+
*
31+
* This lets authors indent a `{% apply markdown_to_html %}` block to match
32+
* the surrounding template without that indentation leaking into Markdown
33+
* (where leading whitespace is significant, e.g. code blocks).
34+
*/
35+
private static function stripCommonIndentation(string $body): string
36+
{
37+
$lines = explode("\n", $body);
38+
39+
$indent = null;
40+
foreach ($lines as $line) {
41+
if ('' === trim($line)) {
42+
continue;
43+
}
44+
45+
$lineIndent = substr($line, 0, strspn($line, " \t"));
46+
if (null === $indent) {
47+
$indent = $lineIndent;
48+
continue;
49+
}
50+
51+
$max = min(\strlen($indent), \strlen($lineIndent));
52+
$common = 0;
53+
while ($common < $max && $indent[$common] === $lineIndent[$common]) {
54+
++$common;
55+
}
56+
$indent = substr($indent, 0, $common);
57+
58+
if ('' === $indent) {
59+
return $body;
60+
}
61+
}
62+
63+
if (null === $indent || '' === $indent) {
64+
return $body;
65+
}
66+
67+
$length = \strlen($indent);
68+
foreach ($lines as $i => $line) {
69+
if (str_starts_with($line, $indent)) {
70+
$lines[$i] = substr($line, $length);
71+
}
2872
}
2973

30-
return $this->converter->convert($body);
74+
return implode("\n", $lines);
3175
}
3276
}

extra/markdown-extra/Tests/FunctionalTest.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use Twig\Extra\Markdown\ErusevMarkdown;
1919
use Twig\Extra\Markdown\LeagueMarkdown;
2020
use Twig\Extra\Markdown\MarkdownExtension;
21+
use Twig\Extra\Markdown\MarkdownInterface;
2122
use Twig\Extra\Markdown\MarkdownRuntime;
2223
use Twig\Extra\Markdown\MichelfMarkdown;
2324
use Twig\Loader\ArrayLoader;
@@ -76,6 +77,42 @@ public static function getMarkdownTests()
7677
{% endapply %}
7778
EOF, "<h1>Hello</h1>\n+<p>Great!</p>"],
7879
["{{ include('html')|markdown_to_html }}", "<h1>Hello</h1>\n+<p>Great!</p>"],
80+
[<<<EOF
81+
{% apply markdown_to_html %}
82+
83+
Paragraph 1
84+
85+
Paragraph 2
86+
{% endapply %}
87+
EOF, "<p>Paragraph 1</p>\n+<p>Paragraph 2</p>"],
88+
];
89+
}
90+
91+
/**
92+
* @dataProvider getIndentationTests
93+
*/
94+
public function testStripsCommonIndentation(string $body, string $expected)
95+
{
96+
$runtime = new MarkdownRuntime(new class implements MarkdownInterface {
97+
public function convert(string $body): string
98+
{
99+
return $body;
100+
}
101+
});
102+
103+
$this->assertSame($expected, $runtime->convert($body));
104+
}
105+
106+
public static function getIndentationTests()
107+
{
108+
return [
109+
'leading blank line keeps blank lines' => ["\nParagraph 1\n\nParagraph 2", "\nParagraph 1\n\nParagraph 2"],
110+
'common indentation is removed' => ["\n Hello\n =====\n\n Great!\n", "\nHello\n=====\n\nGreat!\n"],
111+
'minimal common indentation is removed' => [" a\n b\n", "a\n b\n"],
112+
'indented code block before non-indented prose is preserved' => [" Code\n\nParagraph\n", " Code\n\nParagraph\n"],
113+
'tab indentation is removed' => ["\tHello\n\tGreat!\n", "Hello\nGreat!\n"],
114+
'mixed tabs and spaces are left untouched' => ["\ta\n b\n", "\ta\n b\n"],
115+
'blank lines are ignored when computing indentation' => [" a\n\n b\n", "a\n\nb\n"],
79116
];
80117
}
81118

phpstan-baseline.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
parameters:
22
ignoreErrors:
3+
- # The "$tests" parameter is documented now and will be part of the signature in 4.0
4+
message: '#^PHPDoc tag @param references unknown parameter\: \$tests$#'
5+
identifier: parameter.notFound
6+
count: 1
7+
path: src/Sandbox/SecurityPolicyInterface.php
8+
9+
310
- # 2 parameters will be required
411
message: '#^Method Twig\\Node\\IncludeNode\:\:addGetTemplate\(\) invoked with 2 parameters, 1 required\.$#'
512
identifier: arguments.count

src/ExpressionParser/Infix/DotExpressionParser.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,25 @@ public function parse(Parser $parser, AbstractExpression $expr, Token $token): A
6565
$arguments = $this->parseCallableArguments($parser, $token->getLine());
6666
}
6767

68+
$isMacroTarget = $expr instanceof ContextVariable
69+
&& (
70+
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
71+
|| '_self' === $expr->getAttribute('name')
72+
);
73+
6874
if (
69-
$expr instanceof ContextVariable
75+
$isMacroTarget
7076
&& $attribute instanceof ConstantExpression
7177
&& \is_string($name = $attribute->getAttribute('value'))
7278
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
73-
&& (
74-
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
75-
|| '_self' === $expr->getAttribute('name')
76-
)
7779
) {
7880
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$name, $arguments, $expr->getTemplateLine());
7981
}
8082

83+
if ($isMacroTarget && !$attribute instanceof ConstantExpression) {
84+
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $attribute, $arguments, $expr->getTemplateLine());
85+
}
86+
8187
return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe);
8288
}
8389

src/Extension/CoreExtension.php

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
use Twig\Node\Expression\Unary\SpreadUnary;
8888
use Twig\Node\Expression\Variable\ContextVariable;
8989
use Twig\Node\Node;
90+
use Twig\NodeVisitor\CorrectnessNodeVisitor;
9091
use Twig\Parser;
9192
use Twig\Sandbox\SecurityNotAllowedMethodError;
9293
use Twig\Sandbox\SecurityNotAllowedPropertyError;
@@ -312,25 +313,27 @@ public function getFunctions(): array
312313
public function getTests(): array
313314
{
314315
return [
315-
new TwigTest('even', null, ['node_class' => EvenTest::class]),
316-
new TwigTest('odd', null, ['node_class' => OddTest::class]),
317-
new TwigTest('defined', null, ['node_class' => DefinedTest::class]),
318-
new TwigTest('same as', null, ['node_class' => SameasTest::class, 'one_mandatory_argument' => true]),
319-
new TwigTest('none', null, ['node_class' => NullTest::class]),
320-
new TwigTest('null', null, ['node_class' => NullTest::class]),
321-
new TwigTest('divisible by', null, ['node_class' => DivisiblebyTest::class, 'one_mandatory_argument' => true]),
316+
new TwigTest('even', null, ['node_class' => EvenTest::class, 'always_allowed_in_sandbox' => true]),
317+
new TwigTest('odd', null, ['node_class' => OddTest::class, 'always_allowed_in_sandbox' => true]),
318+
new TwigTest('defined', null, ['node_class' => DefinedTest::class, 'always_allowed_in_sandbox' => true]),
319+
new TwigTest('same as', null, ['node_class' => SameasTest::class, 'one_mandatory_argument' => true, 'always_allowed_in_sandbox' => true]),
320+
new TwigTest('none', null, ['node_class' => NullTest::class, 'always_allowed_in_sandbox' => true]),
321+
new TwigTest('null', null, ['node_class' => NullTest::class, 'always_allowed_in_sandbox' => true]),
322+
new TwigTest('divisible by', null, ['node_class' => DivisiblebyTest::class, 'one_mandatory_argument' => true, 'always_allowed_in_sandbox' => true]),
322323
new TwigTest('constant', null, ['node_class' => ConstantTest::class]),
323-
new TwigTest('empty', self::testEmpty(...)),
324-
new TwigTest('iterable', 'is_iterable'),
325-
new TwigTest('sequence', self::testSequence(...)),
326-
new TwigTest('mapping', self::testMapping(...)),
327-
new TwigTest('true', null, ['node_class' => TrueTest::class]),
324+
new TwigTest('empty', self::testEmpty(...), ['always_allowed_in_sandbox' => true]),
325+
new TwigTest('iterable', 'is_iterable', ['always_allowed_in_sandbox' => true]),
326+
new TwigTest('sequence', self::testSequence(...), ['always_allowed_in_sandbox' => true]),
327+
new TwigTest('mapping', self::testMapping(...), ['always_allowed_in_sandbox' => true]),
328+
new TwigTest('true', null, ['node_class' => TrueTest::class, 'always_allowed_in_sandbox' => true]),
328329
];
329330
}
330331

331332
public function getNodeVisitors(): array
332333
{
333-
return [];
334+
return [
335+
new CorrectnessNodeVisitor(),
336+
];
334337
}
335338

336339
public function getExpressionParsers(): array

src/Extension/SandboxExtension.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,29 @@ public function getSecurityPolicy(): SecurityPolicyInterface
6565
return $this->policy;
6666
}
6767

68-
public function checkSecurity($tags, $filters, $functions, ?Source $source = null): void
68+
public function checkSecurity($tags, $filters, $functions, $tests = [], $source = null): void
6969
{
70-
if ($this->isSandboxed($source)) {
71-
$this->policy->checkSecurity($tags, $filters, $functions);
70+
// BC: previous signature was checkSecurity($tags, $filters, $functions, ?Source $source = null);
71+
// detect a legacy call where the 4th positional argument was the Source.
72+
if ($tests instanceof Source || (null === $tests && \func_num_args() < 5)) {
73+
trigger_deprecation('twig/twig', '3.28', 'Passing a "Twig\Source" as the 4th argument of "%s()" is deprecated; pass an array of tests instead.', __METHOD__);
74+
$source = $tests;
75+
$tests = [];
7276
}
77+
78+
if (!$this->isSandboxed($source)) {
79+
return;
80+
}
81+
82+
if ((new \ReflectionMethod($this->policy, 'checkSecurity'))->getNumberOfParameters() >= 4) {
83+
$this->policy->checkSecurity($tags, $filters, $functions, $tests);
84+
85+
return;
86+
}
87+
88+
trigger_deprecation('twig/twig', '3.28', 'The "%s::checkSecurity()" method will take a 4th "array $tests" argument in 4.0; not declaring it is deprecated.', $this->policy::class);
89+
90+
$this->policy->checkSecurity($tags, $filters, $functions);
7391
}
7492

7593
public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void

0 commit comments

Comments
 (0)