N°9635 - Twig security issues CVE-2026-46633 and CVE-2026-46639 and CVE-2026-46640

This commit is contained in:
Benjamin DALSASS
2026-07-07 13:47:15 +02:00
parent 4b4fe55060
commit 8f87c759b5
112 changed files with 2135 additions and 497 deletions

15
composer.lock generated
View File

@@ -4787,16 +4787,16 @@
},
{
"name": "twig/twig",
"version": "v3.23.0",
"version": "v3.28.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"shasum": ""
},
"require": {
@@ -4806,7 +4806,8 @@
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"php-cs-fixer/shim": "^3.0@stable",
"phpstan/phpstan": "^2.0@stable",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
@@ -4850,7 +4851,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
"source": "https://github.com/twigphp/Twig/tree/v3.28.0"
},
"funding": [
{
@@ -4862,7 +4863,7 @@
"type": "tidelift"
}
],
"time": "2026-01-23T21:00:41+00:00"
"time": "2026-07-03T20:44:34+00:00"
}
],
"packages-dev": [

View File

@@ -2917,6 +2917,7 @@ return array(
'Twig\\Markup' => $vendorDir . '/twig/twig/src/Markup.php',
'Twig\\NodeTraverser' => $vendorDir . '/twig/twig/src/NodeTraverser.php',
'Twig\\NodeVisitor\\AbstractNodeVisitor' => $vendorDir . '/twig/twig/src/NodeVisitor/AbstractNodeVisitor.php',
'Twig\\NodeVisitor\\CorrectnessNodeVisitor' => $vendorDir . '/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php',
'Twig\\NodeVisitor\\EscaperNodeVisitor' => $vendorDir . '/twig/twig/src/NodeVisitor/EscaperNodeVisitor.php',
'Twig\\NodeVisitor\\NodeVisitorInterface' => $vendorDir . '/twig/twig/src/NodeVisitor/NodeVisitorInterface.php',
'Twig\\NodeVisitor\\OptimizerNodeVisitor' => $vendorDir . '/twig/twig/src/NodeVisitor/OptimizerNodeVisitor.php',
@@ -2931,6 +2932,8 @@ return array(
'Twig\\Node\\CheckSecurityCallNode' => $vendorDir . '/twig/twig/src/Node/CheckSecurityCallNode.php',
'Twig\\Node\\CheckSecurityNode' => $vendorDir . '/twig/twig/src/Node/CheckSecurityNode.php',
'Twig\\Node\\CheckToStringNode' => $vendorDir . '/twig/twig/src/Node/CheckToStringNode.php',
'Twig\\Node\\CoercesChildrenToStringInterface' => $vendorDir . '/twig/twig/src/Node/CoercesChildrenToStringInterface.php',
'Twig\\Node\\ConfigNode' => $vendorDir . '/twig/twig/src/Node/ConfigNode.php',
'Twig\\Node\\DeprecatedNode' => $vendorDir . '/twig/twig/src/Node/DeprecatedNode.php',
'Twig\\Node\\DoNode' => $vendorDir . '/twig/twig/src/Node/DoNode.php',
'Twig\\Node\\EmbedNode' => $vendorDir . '/twig/twig/src/Node/EmbedNode.php',
@@ -3069,12 +3072,15 @@ return array(
'Twig\\Sandbox\\SecurityNotAllowedMethodError' => $vendorDir . '/twig/twig/src/Sandbox/SecurityNotAllowedMethodError.php',
'Twig\\Sandbox\\SecurityNotAllowedPropertyError' => $vendorDir . '/twig/twig/src/Sandbox/SecurityNotAllowedPropertyError.php',
'Twig\\Sandbox\\SecurityNotAllowedTagError' => $vendorDir . '/twig/twig/src/Sandbox/SecurityNotAllowedTagError.php',
'Twig\\Sandbox\\SecurityNotAllowedTestError' => $vendorDir . '/twig/twig/src/Sandbox/SecurityNotAllowedTestError.php',
'Twig\\Sandbox\\SecurityPolicy' => $vendorDir . '/twig/twig/src/Sandbox/SecurityPolicy.php',
'Twig\\Sandbox\\SecurityPolicyInterface' => $vendorDir . '/twig/twig/src/Sandbox/SecurityPolicyInterface.php',
'Twig\\Sandbox\\SourcePolicyInterface' => $vendorDir . '/twig/twig/src/Sandbox/SourcePolicyInterface.php',
'Twig\\Source' => $vendorDir . '/twig/twig/src/Source.php',
'Twig\\Template' => $vendorDir . '/twig/twig/src/Template.php',
'Twig\\TemplateWrapper' => $vendorDir . '/twig/twig/src/TemplateWrapper.php',
'Twig\\Test\\IntegrationTestCase' => $vendorDir . '/twig/twig/src/Test/IntegrationTestCase.php',
'Twig\\Test\\NodeTestCase' => $vendorDir . '/twig/twig/src/Test/NodeTestCase.php',
'Twig\\Token' => $vendorDir . '/twig/twig/src/Token.php',
'Twig\\TokenParser\\AbstractTokenParser' => $vendorDir . '/twig/twig/src/TokenParser/AbstractTokenParser.php',
'Twig\\TokenParser\\ApplyTokenParser' => $vendorDir . '/twig/twig/src/TokenParser/ApplyTokenParser.php',
@@ -3104,6 +3110,7 @@ return array(
'Twig\\TwigFunction' => $vendorDir . '/twig/twig/src/TwigFunction.php',
'Twig\\TwigTest' => $vendorDir . '/twig/twig/src/TwigTest.php',
'Twig\\Util\\CallableArgumentsExtractor' => $vendorDir . '/twig/twig/src/Util/CallableArgumentsExtractor.php',
'Twig\\Util\\CallableParameters' => $vendorDir . '/twig/twig/src/Util/CallableParameters.php',
'Twig\\Util\\DeprecationCollector' => $vendorDir . '/twig/twig/src/Util/DeprecationCollector.php',
'Twig\\Util\\ReflectionCallable' => $vendorDir . '/twig/twig/src/Util/ReflectionCallable.php',
'Twig\\Util\\TemplateDirIterator' => $vendorDir . '/twig/twig/src/Util/TemplateDirIterator.php',

View File

@@ -3272,6 +3272,7 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Twig\\Markup' => __DIR__ . '/..' . '/twig/twig/src/Markup.php',
'Twig\\NodeTraverser' => __DIR__ . '/..' . '/twig/twig/src/NodeTraverser.php',
'Twig\\NodeVisitor\\AbstractNodeVisitor' => __DIR__ . '/..' . '/twig/twig/src/NodeVisitor/AbstractNodeVisitor.php',
'Twig\\NodeVisitor\\CorrectnessNodeVisitor' => __DIR__ . '/..' . '/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php',
'Twig\\NodeVisitor\\EscaperNodeVisitor' => __DIR__ . '/..' . '/twig/twig/src/NodeVisitor/EscaperNodeVisitor.php',
'Twig\\NodeVisitor\\NodeVisitorInterface' => __DIR__ . '/..' . '/twig/twig/src/NodeVisitor/NodeVisitorInterface.php',
'Twig\\NodeVisitor\\OptimizerNodeVisitor' => __DIR__ . '/..' . '/twig/twig/src/NodeVisitor/OptimizerNodeVisitor.php',
@@ -3286,6 +3287,8 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Twig\\Node\\CheckSecurityCallNode' => __DIR__ . '/..' . '/twig/twig/src/Node/CheckSecurityCallNode.php',
'Twig\\Node\\CheckSecurityNode' => __DIR__ . '/..' . '/twig/twig/src/Node/CheckSecurityNode.php',
'Twig\\Node\\CheckToStringNode' => __DIR__ . '/..' . '/twig/twig/src/Node/CheckToStringNode.php',
'Twig\\Node\\CoercesChildrenToStringInterface' => __DIR__ . '/..' . '/twig/twig/src/Node/CoercesChildrenToStringInterface.php',
'Twig\\Node\\ConfigNode' => __DIR__ . '/..' . '/twig/twig/src/Node/ConfigNode.php',
'Twig\\Node\\DeprecatedNode' => __DIR__ . '/..' . '/twig/twig/src/Node/DeprecatedNode.php',
'Twig\\Node\\DoNode' => __DIR__ . '/..' . '/twig/twig/src/Node/DoNode.php',
'Twig\\Node\\EmbedNode' => __DIR__ . '/..' . '/twig/twig/src/Node/EmbedNode.php',
@@ -3424,12 +3427,15 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Twig\\Sandbox\\SecurityNotAllowedMethodError' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityNotAllowedMethodError.php',
'Twig\\Sandbox\\SecurityNotAllowedPropertyError' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityNotAllowedPropertyError.php',
'Twig\\Sandbox\\SecurityNotAllowedTagError' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityNotAllowedTagError.php',
'Twig\\Sandbox\\SecurityNotAllowedTestError' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityNotAllowedTestError.php',
'Twig\\Sandbox\\SecurityPolicy' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityPolicy.php',
'Twig\\Sandbox\\SecurityPolicyInterface' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SecurityPolicyInterface.php',
'Twig\\Sandbox\\SourcePolicyInterface' => __DIR__ . '/..' . '/twig/twig/src/Sandbox/SourcePolicyInterface.php',
'Twig\\Source' => __DIR__ . '/..' . '/twig/twig/src/Source.php',
'Twig\\Template' => __DIR__ . '/..' . '/twig/twig/src/Template.php',
'Twig\\TemplateWrapper' => __DIR__ . '/..' . '/twig/twig/src/TemplateWrapper.php',
'Twig\\Test\\IntegrationTestCase' => __DIR__ . '/..' . '/twig/twig/src/Test/IntegrationTestCase.php',
'Twig\\Test\\NodeTestCase' => __DIR__ . '/..' . '/twig/twig/src/Test/NodeTestCase.php',
'Twig\\Token' => __DIR__ . '/..' . '/twig/twig/src/Token.php',
'Twig\\TokenParser\\AbstractTokenParser' => __DIR__ . '/..' . '/twig/twig/src/TokenParser/AbstractTokenParser.php',
'Twig\\TokenParser\\ApplyTokenParser' => __DIR__ . '/..' . '/twig/twig/src/TokenParser/ApplyTokenParser.php',
@@ -3459,6 +3465,7 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Twig\\TwigFunction' => __DIR__ . '/..' . '/twig/twig/src/TwigFunction.php',
'Twig\\TwigTest' => __DIR__ . '/..' . '/twig/twig/src/TwigTest.php',
'Twig\\Util\\CallableArgumentsExtractor' => __DIR__ . '/..' . '/twig/twig/src/Util/CallableArgumentsExtractor.php',
'Twig\\Util\\CallableParameters' => __DIR__ . '/..' . '/twig/twig/src/Util/CallableParameters.php',
'Twig\\Util\\DeprecationCollector' => __DIR__ . '/..' . '/twig/twig/src/Util/DeprecationCollector.php',
'Twig\\Util\\ReflectionCallable' => __DIR__ . '/..' . '/twig/twig/src/Util/ReflectionCallable.php',
'Twig\\Util\\TemplateDirIterator' => __DIR__ . '/..' . '/twig/twig/src/Util/TemplateDirIterator.php',

View File

@@ -5206,17 +5206,17 @@
},
{
"name": "twig/twig",
"version": "v3.23.0",
"version_normalized": "3.23.0.0",
"version": "v3.28.0",
"version_normalized": "3.28.0.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9"
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"reference": "a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b",
"shasum": ""
},
"require": {
@@ -5226,11 +5226,12 @@
"symfony/polyfill-mbstring": "^1.3"
},
"require-dev": {
"phpstan/phpstan": "^2.0",
"php-cs-fixer/shim": "^3.0@stable",
"phpstan/phpstan": "^2.0@stable",
"psr/container": "^1.0|^2.0",
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
},
"time": "2026-01-23T21:00:41+00:00",
"time": "2026-07-03T20:44:34+00:00",
"type": "library",
"installation-source": "dist",
"autoload": {
@@ -5272,7 +5273,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.23.0"
"source": "https://github.com/twigphp/Twig/tree/v3.28.0"
},
"funding": [
{

View File

@@ -1,9 +1,9 @@
<?php return array(
'root' => array(
'name' => 'combodo/itop',
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
'reference' => '4b4fe55060b045962c6954ebff6ca624f87df3f5',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -22,9 +22,9 @@
'dev_requirement' => false,
),
'combodo/itop' => array(
'pretty_version' => '1.0.0+no-version-set',
'version' => '1.0.0.0',
'reference' => null,
'pretty_version' => 'dev-develop',
'version' => 'dev-develop',
'reference' => '4b4fe55060b045962c6954ebff6ca624f87df3f5',
'type' => 'project',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -679,9 +679,9 @@
'dev_requirement' => false,
),
'twig/twig' => array(
'pretty_version' => 'v3.23.0',
'version' => '3.23.0.0',
'reference' => 'a64dc5d2cc7d6cafb9347f6cd802d0d06d0351c9',
'pretty_version' => 'v3.28.0',
'version' => '3.28.0.0',
'reference' => '597c12ed286fb9d1701a36684ce6e0cbe28ebc8b',
'type' => 'library',
'install_path' => __DIR__ . '/../twig/twig',
'aliases' => array(),

View File

@@ -1,3 +1,85 @@
# 3.28.0 (2026-07-03)
* Render backed enums using their backing value in the `html_attr` function
* Fix empty Markup values being treated as truthy in and, or, xor, not, ternary, and elvis expressions
* Fix a PHP 8.5 `chr()` deprecation when decoding an octal string escape sequence larger than `\377` (such as `"\777"`)
* Mark `Twig\Markup` as `@final`; it will be final in Twig 4.0
* Reduce memory usage and speed up the context restoration compiled at the end of `for` loops
* Allow calling a macro with a dynamic name via the dot operator (`macros.(name)(args)`)
* Report the column number in syntax errors and expose it via `Error::getTemplateColumn()`
* Track the source offset of each token and expose it via `Token::getOffset()`
* Fix nested `block()` calls to resolve against the overriding template when a block rendered through `block(name, template)` calls `parent()`
* Deprecate the possibility to use a `block` tag within a capture node (like `set`)
* Deprecate the possibility to use a `block` tag within a capture node (like `set`) in child templates
* Deprecate using a `macro`, `extends`, or `use` tag outside the root of a template
* Stop reporting a skipped test in `IntegrationTestCase` when there is no legacy test to run
* Fix `markdown_to_html` to strip the indentation shared by all lines instead of mangling content that starts with a blank line
* 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
* Document the criteria for marking a callable or tag as always allowed in a sandbox, and the list of built-in tags, filters, and functions that will be always allowed in Twig 4.0
* Add an `always_allowed_in_sandbox` option for filters, functions, and tests, and an `isAlwaysAllowedInSandbox()` method for token parsers, to let authors mark callables and tags that are always allowed in sandbox mode without explicit allow-listing
* Add an allow-list for tests to `Twig\Sandbox\SecurityPolicy`, with the safe built-in tests flagged as always allowed so they keep working without allow-listing
# 3.27.1 (2026-05-30)
* Fix array access with a `Stringable` key to coerce the key to string consistently instead of throwing in the optimized path
* Fix sandbox replacing `IteratorAggregate` arguments (e.g. Symfony's `FormView`) by a plain array
# 3.27.0 (2026-05-27)
* Add a strict mode to `Twig\Sandbox\SecurityPolicy` to opt-in to the 4.0 behavior for the `extends`/`use` tags and the `parent`/`block`/`attribute` functions, which are otherwise still implicitly allowed in a sandbox
* Deprecate the fact that the `parent`, `block`, and `attribute` functions are always allowed in a sandboxed template
* Fix sandbox filter/tag/function allow-list bypass when the sandbox state changed between renders of a cached `Template` instance
* Fix PHP 8.1+ implicit float-to-int deprecation triggered by sandboxed `ArrayAccess` attribute access with a float key
* Restrict allowed classes in `Twig\Profiler\Profile::unserialize()` to prevent arbitrary class instantiation
* Escape root profile name in `HtmlDumper`
* Fix sandbox bypass in deprecated internal wrappers `twig_array_some()`, `twig_array_every()`, and `twig_check_arrow_in_sandbox()` (`src/Resources/core.php`)
* Deprecate the `Twig\Sandbox\SourcePolicyInterface` interface with no replacement
* Fix sandbox bypass in the "column" filter when sandboxing is enabled via `SourcePolicyInterface`
* Fix sandbox `__toString` bypass via `Traversable` arguments to the `join` and `replace` filters (also covers containers that implement both `Stringable` and `Traversable`)
* Fix sandbox `__toString` bypass via the `in` and `not in` operators
* Prevent a stack overflow in `SandboxExtension::ensureToStringAllowed()` when a self-referencing iterable is passed to a sandboxed template
* Add support for any expression as a dynamic mapping key (attribute access, filters, ...)
* Fix sandbox `__toString` policy bypass via dynamic mapping keys
# 3.26.0 (2026-05-20)
* Document that the sandbox doesn't protect against resource exhaustion
* Document `template_from_string` caveats when used in a sandboxed environment
* Add docs on `Markup` about the goal of this class in the context of a sandbox
* Pre-escape HTML input on the `spaceless` filter
* Pre-escape HTML input on `inline_css` and `inky_to_html` filters
* Fix XSS by adjusting `is_safe` annotation on HTML-emitting filters
* [Profiler] Escape template and profile names in `HtmlDumper`
* Fix unbounded memoisation of `IntlDateFormatter` / `NumberFormatter`
* Fix sandbox bypass in the "column" filter
* Fix sandbox bypass in the `{% sandbox %}` tag when including a preloaded template
* Fix sandbox bypass: PHP code injection via `{% use %}` template name
* Fix sandbox bypass: PHP code injection via `_self` / import macro reference
* Fix sandbox bypass in object destructuring assignment
* Fix sandbox bypass: propagate `Source` to `checkArrow` for source-policy sandboxing
* Encode single quotes as `\x27` in `Compiler::string()` as a defense-in-depth measure
* Fix sandbox `__toString` bypasses
* Add `Twig\Node\CoercesChildrenToStringInterface` to let nodes declare which of their child nodes will be string-coerced at runtime so the sandbox wraps them with a `__toString` check
# 3.25.0 (2026-05-17)
* Add a `needs_is_sandboxed` option for filters, functions, and tests
* Use deterministic suffixes for generated embed classes
* Lazy-load `EscaperRuntime` in `EscaperExtension`
# 3.24.0 (2026-03-17)
* Deprecate not implementing the `getOperatorTokens()` method in `ExpressionParserInterface` implementations
* Deprecate passing a non-`AbstractExpression` node to `Twig\Node\Expression\Binary\MatchesBinary` constructor
* Deprecate passing a non-`AbstractExpression` node to `Parser::setParent()`
* Add support for renaming variables in object destructuring (`{name: userName} = user`)
* Add `html_attr_relaxed` escaping strategy that preserves :, @, [, and ] for front-end framework attribute names
* Add support for short-circuiting in null-safe operator chains
* Add the `html_attr` function and `html_attr_merge` as well as `html_attr_type` filters
# 3.23.0 (2026-01-23)
* Add `=` assignment operator (allows to set variables in expression or to replace the short-form of the set tag)

View File

@@ -32,7 +32,8 @@
"require-dev": {
"symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0",
"psr/container": "^1.0|^2.0",
"phpstan/phpstan": "^2.0"
"phpstan/phpstan": "^2.0@stable",
"php-cs-fixer/shim": "^3.0@stable"
},
"autoload": {
"files": [

View File

@@ -1,15 +0,0 @@
{
"subtrees": {
"twig-extra-bundle": "extra/twig-extra-bundle",
"cache-extra": "extra/cache-extra",
"cssinliner-extra": "extra/cssinliner-extra",
"html-extra": "extra/html-extra",
"inky-extra": "extra/inky-extra",
"intl-extra": "extra/intl-extra",
"markdown-extra": "extra/markdown-extra",
"string-extra": "extra/string-extra"
},
"defaults": {
"git_constraint": "<1.8.2"
}
}

View File

@@ -32,7 +32,9 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
'needs_environment' => false,
'needs_context' => false,
'needs_charset' => false,
'needs_is_sandboxed' => false,
'is_variadic' => false,
'always_allowed_in_sandbox' => false,
'deprecation_info' => null,
'deprecated' => false,
'deprecating_package' => '',
@@ -107,6 +109,16 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
return $this->options['needs_context'];
}
public function needsIsSandboxed(): bool
{
return $this->options['needs_is_sandboxed'];
}
public function isAlwaysAllowedInSandbox(): bool
{
return $this->options['always_allowed_in_sandbox'];
}
/**
* @return static
*/
@@ -182,6 +194,6 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
public function getMinimalNumberOfRequiredArguments(): int
{
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + \count($this->arguments);
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + ($this->options['needs_is_sandboxed'] ? 1 : 0) + \count($this->arguments);
}
}

View File

@@ -31,26 +31,30 @@ use Twig\TwigFilter;
final class AsTwigFilter
{
/**
* @param non-empty-string $name The name of the filter in Twig
* @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe
* @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
* @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param non-empty-string $name The name of the filter in Twig
* @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the filter needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe
* @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
* @param string[]|null $preservesSafety Preserves the safety of the value that the filter is applied to
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param bool|null $alwaysAllowedInSandbox Whether the filter is always allowed in sandbox mode, even when not explicitly allow-listed
*/
public function __construct(
public string $name,
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?string $preEscape = null,
public ?array $preservesSafety = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
public ?bool $alwaysAllowedInSandbox = null,
) {
}
}

View File

@@ -31,22 +31,26 @@ use Twig\TwigFunction;
final class AsTwigFunction
{
/**
* @param non-empty-string $name The name of the function in Twig
* @param bool|null $needsCharset Whether the function needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param non-empty-string $name The name of the function in Twig
* @param bool|null $needsCharset Whether the function needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the function needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param bool|null $alwaysAllowedInSandbox Whether the function is always allowed in sandbox mode, even when not explicitly allow-listed
*/
public function __construct(
public string $name,
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
public ?bool $alwaysAllowedInSandbox = null,
) {
}
}

View File

@@ -31,18 +31,22 @@ use Twig\TwigTest;
final class AsTwigTest
{
/**
* @param non-empty-string $name The name of the test in Twig
* @param bool|null $needsCharset Whether the test needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param non-empty-string $name The name of the test in Twig
* @param bool|null $needsCharset Whether the test needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the test needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
* @param bool|null $alwaysAllowedInSandbox Whether the test is always allowed in sandbox mode, even when not explicitly allow-listed
*/
public function __construct(
public string $name,
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
public ?bool $alwaysAllowedInSandbox = null,
) {
}
}

View File

@@ -143,7 +143,13 @@ class Compiler
*/
public function string(string $value)
{
$this->source .= \sprintf('"%s"', addcslashes($value, "\0\t\"\$\\"));
// Single quotes are encoded as \x27 (not \') as a defense-in-depth measure:
// it guarantees that the compiled output never contains a literal "'" derived
// from user input, which prevents breaking out of a surrounding single-quoted
// PHP context if a caller mistakenly concatenates the result into one.
// \' is not a recognized escape sequence in PHP double-quoted strings (the
// backslash would be kept literally), so \x27 is used instead.
$this->source .= \sprintf('"%s"', str_replace("'", '\\x27', addcslashes($value, "\0\t\"\$\\")));
return $this;
}

View File

@@ -43,10 +43,10 @@ use Twig\TokenParser\TokenParserInterface;
*/
class Environment
{
public const VERSION = '3.23.0';
public const VERSION_ID = 32300;
public const VERSION = '3.28.0';
public const VERSION_ID = 32800;
public const MAJOR_VERSION = 3;
public const MINOR_VERSION = 23;
public const MINOR_VERSION = 28;
public const RELEASE_VERSION = 0;
public const EXTRA_VERSION = '';

View File

@@ -36,6 +36,8 @@ use Twig\Template;
class Error extends \Exception
{
private $lineno;
/** @var positive-int|null */
private ?int $columnno;
private $rawMessage;
private ?Source $source;
private string $phpFile;
@@ -46,17 +48,19 @@ class Error extends \Exception
*
* By default, automatic guessing is enabled.
*
* @param string $message The error message
* @param int $lineno The template line where the error occurred
* @param Source|null $source The source context where the error occurred
* @param string $message The error message
* @param int $lineno The template line where the error occurred
* @param Source|null $source The source context where the error occurred
* @param positive-int|null $columnno The template column where the error occurred
*/
public function __construct(string $message, int $lineno = -1, ?Source $source = null, ?\Throwable $previous = null)
public function __construct(string $message, int $lineno = -1, ?Source $source = null, ?\Throwable $previous = null, ?int $columnno = null)
{
parent::__construct('', 0, $previous);
$this->phpFile = $this->getFile();
$this->phpLine = $this->getLine();
$this->lineno = $lineno;
$this->columnno = $columnno;
$this->source = $source;
$this->rawMessage = $message;
$this->updateRepr();
@@ -78,6 +82,25 @@ class Error extends \Exception
$this->updateRepr();
}
/**
* Returns the 1-based column where the error occurred, or null if unknown.
*
* @return positive-int|null
*/
public function getTemplateColumn(): ?int
{
return $this->columnno;
}
/**
* @param positive-int|null $columnno
*/
public function setTemplateColumn(?int $columnno): void
{
$this->columnno = $columnno;
$this->updateRepr();
}
public function getSourceContext(): ?Source
{
return $this->source;
@@ -127,6 +150,9 @@ class Error extends \Exception
}
if ($this->lineno > 0) {
$this->message .= \sprintf(' at line %d', $this->lineno);
if (null !== $this->columnno) {
$this->message .= \sprintf(' column %d', $this->columnno);
}
}
if ($punctuation) {
$this->message .= $punctuation;

View File

@@ -27,4 +27,9 @@ abstract class AbstractExpressionParser implements ExpressionParserInterface
{
return [];
}
public function getOperatorTokens(): array
{
return [$this->getName(), ...$this->getAliases()];
}
}

View File

@@ -11,6 +11,14 @@
namespace Twig\ExpressionParser;
/**
* @method list<string> getOperatorTokens() Returns the operator token strings that this expression parser handles.
* These are the strings that should be recognized as operator tokens by the Lexer,
* and used to look up the parser in the registry.
* For most parsers, this returns the name and aliases. Parsers that don't handle
* operator tokens (like LiteralExpressionParser) should return an empty array.
* This method will be added to the interface in Twig 4.0.
*/
interface ExpressionParserInterface
{
public function __toString(): string;

View File

@@ -54,10 +54,9 @@ final class ExpressionParsers implements \IteratorAggregate
// throw new \InvalidArgumentException(\sprintf('Precedence for "%s" must be between 0 and 512, got %d.', $parser->getName(), $parser->getPrecedence()));
}
$interface = $parser instanceof PrefixExpressionParserInterface ? PrefixExpressionParserInterface::class : InfixExpressionParserInterface::class;
$this->parsersByName[$interface][$parser->getName()] = $parser;
$this->parsersByClass[$parser::class] = $parser;
foreach ($parser->getAliases() as $alias) {
$this->parsersByName[$interface][$alias] = $parser;
foreach (self::getOperatorTokensFor($parser) as $token) {
$this->parsersByName[$interface][$token] = $parser;
}
}
@@ -90,9 +89,22 @@ final class ExpressionParsers implements \IteratorAggregate
public function getIterator(): \Traversable
{
$seen = [];
foreach ($this->parsersByName as $parsers) {
// we don't yield the keys
yield from $parsers;
foreach ($parsers as $parser) {
$id = spl_object_id($parser);
if (!isset($seen[$id])) {
$seen[$id] = true;
yield $parser;
}
}
}
foreach ($this->parsersByClass as $parser) {
$id = spl_object_id($parser);
if (!isset($seen[$id])) {
$seen[$id] = true;
yield $parser;
}
}
}
@@ -124,4 +136,20 @@ final class ExpressionParsers implements \IteratorAggregate
return $this->precedenceChanges;
}
/**
* @internal
*
* @return array<string>
*/
public static function getOperatorTokensFor(ExpressionParserInterface $parser): array
{
if (method_exists($parser, 'getOperatorTokens')) {
return $parser->getOperatorTokens();
}
trigger_deprecation('twig/twig', '3.24', 'Not implementing the "getOperatorTokens()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $parser::class, ExpressionParserInterface::class);
return [$parser->getName(), ...$parser->getAliases()];
}
}

View File

@@ -51,12 +51,12 @@ class AssignmentExpressionParser extends BinaryOperatorExpressionParser
if ($left instanceof ArrayExpression) {
if ($left->isSequence()) {
return new SequenceDestructuringSetBinary($left, $right, $token->getLine());
} else {
return new ObjectDestructuringSetBinary($left, $right, $token->getLine());
}
} else {
return new SetBinary($left, $right, $token->getLine());
return new ObjectDestructuringSetBinary($left, $right, $token->getLine());
}
return new SetBinary($left, $right, $token->getLine());
}
public function getDescription(): string

View File

@@ -65,14 +65,23 @@ final class DotExpressionParser extends AbstractExpressionParser implements Infi
$arguments = $this->parseCallableArguments($parser, $token->getLine());
}
if (
$expr instanceof NameExpression
$isMacroTarget = $expr instanceof NameExpression
&& (
null !== $parser->getImportedSymbol('template', $expr->getAttribute('name'))
|| '_self' === $expr->getAttribute('name') && $attribute instanceof ConstantExpression
)
|| '_self' === $expr->getAttribute('name')
);
if (
$isMacroTarget
&& $attribute instanceof ConstantExpression
&& \is_string($name = $attribute->getAttribute('value'))
&& preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)
) {
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$attribute->getAttribute('value'), $arguments, $expr->getTemplateLine());
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), 'macro_'.$name, $arguments, $expr->getTemplateLine());
}
if ($isMacroTarget && !$attribute instanceof ConstantExpression) {
return new MacroReferenceExpression(new TemplateVariable($expr->getAttribute('name'), $expr->getTemplateLine()), $attribute, $arguments, $expr->getTemplateLine());
}
return new GetAttrExpression($expr, $attribute, $arguments, $type, $lineno, $nullSafe);

View File

@@ -54,7 +54,14 @@ final class FunctionExpressionParser extends AbstractExpressionParser implements
$fakeNode = new EmptyNode($line);
$fakeNode->setSourceContext($parser->getStream()->getSourceContext());
return ($function->getParserCallable())($parser, $fakeNode, $args, $line);
$node = ($function->getParserCallable())($parser, $fakeNode, $args, $line);
// remember the original function name so the sandbox can enforce
// the `allowedFunctions` allow-list even though the parser callable
// returned a specialized node (e.g. `parent`, `block`, `attribute`).
$node->setAttribute('sandboxed_function_name', $name);
$node->setAttribute('sandboxed_function', $function);
return $node;
}
if (!isset($this->readyNodes[$class = $function->getNodeClass()])) {

View File

@@ -17,6 +17,7 @@ use Twig\ExpressionParser\ExpressionParserDescriptionInterface;
use Twig\ExpressionParser\PrefixExpressionParserInterface;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ListExpression;
use Twig\Node\Expression\Variable\AssignContextVariable;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Parser;
use Twig\Token;
@@ -36,7 +37,7 @@ final class GroupingExpressionParser extends AbstractExpressionParser implements
return $expr->setExplicitParentheses();
}
return new ListExpression([$expr], $token->getLine());
return new ListExpression([self::toAssignContextVariable($expr)], $token->getLine());
}
// determine if we are parsing an arrow function arguments
@@ -58,7 +59,16 @@ final class GroupingExpressionParser extends AbstractExpressionParser implements
throw new SyntaxError('A list of variables must be followed by an arrow.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
return new ListExpression($names, $token->getLine());
return new ListExpression(array_map(self::toAssignContextVariable(...), $names), $token->getLine());
}
private static function toAssignContextVariable(AbstractExpression $expr): AssignContextVariable
{
if (!$expr instanceof ContextVariable) {
throw new SyntaxError('A list must only contain variables.', $expr->getTemplateLine(), $expr->getSourceContext());
}
return $expr instanceof AssignContextVariable ? $expr : new AssignContextVariable($expr->getAttribute('name'), $expr->getTemplateLine());
}
public function getName(): string

View File

@@ -30,8 +30,6 @@ use Twig\Token;
*/
final class LiteralExpressionParser extends AbstractExpressionParser implements PrefixExpressionParserInterface, ExpressionParserDescriptionInterface
{
private string $type = 'literal';
public function parse(Parser $parser, Token $token): AbstractExpression
{
$stream = $parser->getStream();
@@ -41,41 +39,30 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
switch ($token->getValue()) {
case 'true':
case 'TRUE':
$this->type = 'constant';
return new ConstantExpression(true, $token->getLine());
case 'false':
case 'FALSE':
$this->type = 'constant';
return new ConstantExpression(false, $token->getLine());
case 'none':
case 'NONE':
case 'null':
case 'NULL':
$this->type = 'constant';
return new ConstantExpression(null, $token->getLine());
default:
$this->type = 'variable';
return new ContextVariable($token->getValue(), $token->getLine());
}
// no break
case $token->test(Token::NUMBER_TYPE):
$stream->next();
$this->type = 'constant';
return new ConstantExpression($token->getValue(), $token->getLine());
case $token->test(Token::STRING_TYPE):
case $token->test(Token::INTERPOLATION_START_TYPE):
$this->type = 'string';
return $this->parseStringExpression($parser);
case $token->test(Token::PUNCTUATION_TYPE):
@@ -96,7 +83,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
if (preg_match(Lexer::REGEX_NAME, $token->getValue(), $matches) && $matches[0] == $token->getValue()) {
// in this context, string operators are variable names
$stream->next();
$this->type = 'variable';
return new ContextVariable($token->getValue(), $token->getLine());
}
@@ -109,7 +95,12 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
public function getName(): string
{
return $this->type;
return 'literal';
}
public function getOperatorTokens(): array
{
return [];
}
public function getDescription(): string
@@ -153,8 +144,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
private function parseSequenceExpression(Parser $parser)
{
$this->type = 'sequence';
$stream = $parser->getStream();
$stream->expect(Token::OPERATOR_TYPE, '[', 'A sequence element was expected');
@@ -185,8 +174,6 @@ final class LiteralExpressionParser extends AbstractExpressionParser implements
private function parseMappingExpression(Parser $parser)
{
$this->type = 'mapping';
$stream = $parser->getStream();
$stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected');

View File

@@ -95,11 +95,13 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
'pre_escape' => $attribute->preEscape,
'preserves_safety' => $attribute->preservesSafety,
'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false,
'deprecation_info' => $attribute->deprecationInfo,
]);
@@ -118,9 +120,11 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false,
'deprecation_info' => $attribute->deprecationInfo,
]);
@@ -139,7 +143,9 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'always_allowed_in_sandbox' => $attribute->alwaysAllowedInSandbox ?? false,
'deprecation_info' => $attribute->deprecationInfo,
]);

View File

@@ -85,6 +85,7 @@ use Twig\Node\Expression\Unary\NotUnary;
use Twig\Node\Expression\Unary\PosUnary;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Node;
use Twig\NodeVisitor\CorrectnessNodeVisitor;
use Twig\Parser;
use Twig\Sandbox\SecurityNotAllowedMethodError;
use Twig\Sandbox\SecurityNotAllowedPropertyError;
@@ -134,7 +135,7 @@ final class CoreExtension extends AbstractExtension
private $dateFormats = ['F j, Y H:i', '%d days'];
private $numberFormat = [0, '.', ','];
private $timezone = null;
private $timezone;
/**
* Sets the default format to be used by the date filter.
@@ -258,19 +259,19 @@ final class CoreExtension extends AbstractExtension
new TwigFilter('striptags', [self::class, 'striptags']),
new TwigFilter('trim', [self::class, 'trim']),
new TwigFilter('nl2br', [self::class, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
new TwigFilter('spaceless', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12')]),
new TwigFilter('spaceless', [self::class, 'spaceless'], ['pre_escape' => 'html', 'is_safe' => ['html'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12')]),
// array helpers
new TwigFilter('join', [self::class, 'join']),
new TwigFilter('split', [self::class, 'split'], ['needs_charset' => true]),
new TwigFilter('sort', [self::class, 'sort'], ['needs_environment' => true]),
new TwigFilter('sort', [self::class, 'sort'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('merge', [self::class, 'merge']),
new TwigFilter('batch', [self::class, 'batch']),
new TwigFilter('column', [self::class, 'column']),
new TwigFilter('filter', [self::class, 'filter'], ['needs_environment' => true]),
new TwigFilter('map', [self::class, 'map'], ['needs_environment' => true]),
new TwigFilter('reduce', [self::class, 'reduce'], ['needs_environment' => true]),
new TwigFilter('find', [self::class, 'find'], ['needs_environment' => true]),
new TwigFilter('column', [self::class, 'column'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('filter', [self::class, 'filter'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('map', [self::class, 'map'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('reduce', [self::class, 'reduce'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
new TwigFilter('find', [self::class, 'find'], ['needs_environment' => true, 'needs_is_sandboxed' => true]),
// string/array filters
new TwigFilter('reverse', [self::class, 'reverse'], ['needs_charset' => true]),
@@ -310,25 +311,27 @@ final class CoreExtension extends AbstractExtension
public function getTests(): array
{
return [
new TwigTest('even', null, ['node_class' => EvenTest::class]),
new TwigTest('odd', null, ['node_class' => OddTest::class]),
new TwigTest('defined', null, ['node_class' => DefinedTest::class]),
new TwigTest('same as', null, ['node_class' => SameasTest::class, 'one_mandatory_argument' => true]),
new TwigTest('none', null, ['node_class' => NullTest::class]),
new TwigTest('null', null, ['node_class' => NullTest::class]),
new TwigTest('divisible by', null, ['node_class' => DivisiblebyTest::class, 'one_mandatory_argument' => true]),
new TwigTest('even', null, ['node_class' => EvenTest::class, 'always_allowed_in_sandbox' => true]),
new TwigTest('odd', null, ['node_class' => OddTest::class, 'always_allowed_in_sandbox' => true]),
new TwigTest('defined', null, ['node_class' => DefinedTest::class, 'always_allowed_in_sandbox' => true]),
new TwigTest('same as', null, ['node_class' => SameasTest::class, 'one_mandatory_argument' => true, 'always_allowed_in_sandbox' => true]),
new TwigTest('none', null, ['node_class' => NullTest::class, 'always_allowed_in_sandbox' => true]),
new TwigTest('null', null, ['node_class' => NullTest::class, 'always_allowed_in_sandbox' => true]),
new TwigTest('divisible by', null, ['node_class' => DivisiblebyTest::class, 'one_mandatory_argument' => true, 'always_allowed_in_sandbox' => true]),
new TwigTest('constant', null, ['node_class' => ConstantTest::class]),
new TwigTest('empty', [self::class, 'testEmpty']),
new TwigTest('iterable', 'is_iterable'),
new TwigTest('sequence', [self::class, 'testSequence']),
new TwigTest('mapping', [self::class, 'testMapping']),
new TwigTest('true', null, ['node_class' => TrueTest::class]),
new TwigTest('empty', [self::class, 'testEmpty'], ['always_allowed_in_sandbox' => true]),
new TwigTest('iterable', 'is_iterable', ['always_allowed_in_sandbox' => true]),
new TwigTest('sequence', [self::class, 'testSequence'], ['always_allowed_in_sandbox' => true]),
new TwigTest('mapping', [self::class, 'testMapping'], ['always_allowed_in_sandbox' => true]),
new TwigTest('true', null, ['node_class' => TrueTest::class, 'always_allowed_in_sandbox' => true]),
];
}
public function getNodeVisitors(): array
{
return [];
return [
new CorrectnessNodeVisitor(),
];
}
public function getExpressionParsers(): array
@@ -1044,7 +1047,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function sort(Environment $env, $array, $arrow = null): array
public static function sort(Environment $env, bool $isSandboxed, $array, $arrow = null): array
{
if ($array instanceof \Traversable) {
$array = iterator_to_array($array);
@@ -1053,7 +1056,7 @@ final class CoreExtension extends AbstractExtension
}
if (null !== $arrow) {
self::checkArrow($env, $arrow, 'sort', 'filter');
self::checkArrow($isSandboxed, $arrow, 'sort', 'filter');
uasort($array, $arrow);
} else {
@@ -1128,9 +1131,9 @@ final class CoreExtension extends AbstractExtension
}
if ((int) $bTrim == $bTrim) {
return $a <=> (int) $bTrim;
} else {
return (float) $a <=> (float) $bTrim;
}
return (float) $a <=> (float) $bTrim;
}
if (\is_string($a) && \is_int($b)) {
$aTrim = trim($a, " \t\n\r\v\f");
@@ -1139,9 +1142,9 @@ final class CoreExtension extends AbstractExtension
}
if ((int) $aTrim == $aTrim) {
return (int) $aTrim <=> $b;
} else {
return (float) $aTrim <=> (float) $b;
}
return (float) $aTrim <=> (float) $b;
}
// float <=> string
@@ -1179,7 +1182,7 @@ final class CoreExtension extends AbstractExtension
*/
public static function matches(string $regexp, ?string $str): int
{
set_error_handler(function ($t, $m) use ($regexp) {
set_error_handler(static function ($t, $m) use ($regexp) {
throw new RuntimeError(\sprintf('Regexp "%s" passed to "matches" is not valid', $regexp).substr($m, 12));
});
try {
@@ -1490,9 +1493,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,11 +1524,9 @@ final class CoreExtension extends AbstractExtension
return '';
}
if ($isSandboxed) {
$loaded->unwrap()->checkSecurity();
}
$rendered = $loaded->render($variables);
return $loaded->render($variables);
return '' === $rendered ? '' : new Markup($rendered, $env->getCharset());
} finally {
if ($isSandboxed && !$alreadySandboxed) {
$sandbox->disableSandbox();
@@ -1686,6 +1689,9 @@ final class CoreExtension extends AbstractExtension
public static function getAttribute(Environment $env, Source $source, $object, $item, array $arguments = [], $type = Template::ANY_CALL, $isDefinedTest = false, $ignoreStrictCheck = false, $sandboxed = false, int $lineno = -1)
{
$propertyNotAllowedError = null;
if ($sandboxed && $item instanceof \Stringable) {
$env->getExtension(SandboxExtension::class)->ensureToStringAllowed($item, $lineno, $source);
}
// array
if (Template::METHOD_CALL !== $type) {
@@ -1695,6 +1701,10 @@ final class CoreExtension extends AbstractExtension
try {
$env->getExtension(SandboxExtension::class)->checkPropertyAllowed($object, $arrayItem, $lineno, $source);
} catch (SecurityNotAllowedPropertyError $propertyNotAllowedError) {
// The methodCheck path expects $item to be a string; stringify it here
// to avoid PHP 8.1+ implicit float-to-int deprecations on downstream
// array key lookups (e.g. isset($cache[$class][$item])).
$item = (string) $item;
goto methodCheck;
}
}
@@ -1788,10 +1798,6 @@ final class CoreExtension extends AbstractExtension
static $propertyCheckers = [];
if ($object instanceof \Closure && '__invoke' === $item) {
return $isDefinedTest ? true : $object();
}
if (isset($object->$item)
|| ($propertyCheckers[$object::class][$item] ??= self::getPropertyChecker($object::class, $item))($object, $item)
) {
@@ -1840,14 +1846,14 @@ final class CoreExtension extends AbstractExtension
$classCache[$lcName = $lcMethods[$i]] = $method;
if ('g' === $lcName[0] && str_starts_with($lcName, 'get')) {
$name = substr($method, 3);
$lcName = substr($lcName, 3);
$prefixLength = 3;
$lcName = substr($lcName, $prefixLength);
} elseif ('i' === $lcName[0] && str_starts_with($lcName, 'is')) {
$name = substr($method, 2);
$lcName = substr($lcName, 2);
$prefixLength = 2;
$lcName = substr($lcName, $prefixLength);
} elseif ('h' === $lcName[0] && str_starts_with($lcName, 'has')) {
$name = substr($method, 3);
$lcName = substr($lcName, 3);
$prefixLength = 3;
$lcName = substr($lcName, $prefixLength);
if (\in_array('is'.$lcName, $lcMethods, true)) {
continue;
}
@@ -1855,8 +1861,11 @@ final class CoreExtension extends AbstractExtension
continue;
}
// skip get() and is() methods (in which case, $name is empty)
if ($name) {
// skip get(), is() and has() methods (in which case, $lcName is empty)
if ($lcName) {
// camelCase name (e.g. getFooBar() -> fooBar)
$name = $lcName[0].substr($method, $prefixLength + 1);
if (!isset($classCache[$name])) {
$classCache[$name] = $method;
}
@@ -1946,7 +1955,7 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function column($array, $name, $index = null): array
public static function column(Environment $env, bool $isSandboxed, $array, $name, $index = null): array
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "column" filter expects a sequence or a mapping, got "%s".', get_debug_type($array)));
@@ -1956,6 +1965,21 @@ final class CoreExtension extends AbstractExtension
$array = iterator_to_array($array);
}
if ($isSandboxed) {
// The sandbox might be enabled via a SourcePolicyInterface, in which case the SandboxExtension
// would not consider the sandbox active without the current Source: $isSandboxed is already
// computed against the call-site source, so check the policy directly to honor that decision.
$policy = $env->getExtension(SandboxExtension::class)->getSecurityPolicy();
foreach ($array as $item) {
if (\is_object($item)) {
$policy->checkPropertyAllowed($item, (string) $name);
if (null !== $index) {
$policy->checkPropertyAllowed($item, (string) $index);
}
}
}
}
return array_column($array, $name, $index);
}
@@ -1964,13 +1988,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function filter(Environment $env, $array, $arrow)
public static function filter(Environment $env, bool $isSandboxed, $array, $arrow)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "filter" filter expects a sequence/mapping or "Traversable", got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'filter', 'filter');
self::checkArrow($isSandboxed, $arrow, 'filter', 'filter');
if (\is_array($array)) {
return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);
@@ -1985,13 +2009,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function find(Environment $env, $array, $arrow)
public static function find(Environment $env, bool $isSandboxed, $array, $arrow)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "find" filter expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'find', 'filter');
self::checkArrow($isSandboxed, $arrow, 'find', 'filter');
foreach ($array as $k => $v) {
if ($arrow($v, $k)) {
@@ -2007,13 +2031,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function map(Environment $env, $array, $arrow)
public static function map(Environment $env, bool $isSandboxed, $array, $arrow)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "map" filter expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'map', 'filter');
self::checkArrow($isSandboxed, $arrow, 'map', 'filter');
$r = [];
foreach ($array as $k => $v) {
@@ -2028,13 +2052,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function reduce(Environment $env, $array, $arrow, $initial = null)
public static function reduce(Environment $env, bool $isSandboxed, $array, $arrow, $initial = null)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "reduce" filter expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'reduce', 'filter');
self::checkArrow($isSandboxed, $arrow, 'reduce', 'filter');
$accumulator = $initial;
foreach ($array as $key => $value) {
@@ -2049,13 +2073,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function arraySome(Environment $env, $array, $arrow)
public static function arraySome(Environment $env, $array, $arrow, bool $isSandboxed = false)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "has some" test expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'has some', 'operator');
self::checkArrow($isSandboxed, $arrow, 'has some', 'operator');
foreach ($array as $k => $v) {
if ($arrow($v, $k)) {
@@ -2071,13 +2095,13 @@ final class CoreExtension extends AbstractExtension
*
* @internal
*/
public static function arrayEvery(Environment $env, $array, $arrow)
public static function arrayEvery(Environment $env, $array, $arrow, bool $isSandboxed = false)
{
if (!is_iterable($array)) {
throw new RuntimeError(\sprintf('The "has every" test expects a sequence or a mapping, got "%s".', get_debug_type($array)));
}
self::checkArrow($env, $arrow, 'has every', 'operator');
self::checkArrow($isSandboxed, $arrow, 'has every', 'operator');
foreach ($array as $k => $v) {
if (!$arrow($v, $k)) {
@@ -2091,13 +2115,13 @@ final class CoreExtension extends AbstractExtension
/**
* @internal
*/
public static function checkArrow(Environment $env, $arrow, $thing, $type)
public static function checkArrow(bool $isSandboxed, $arrow, $thing, $type)
{
if ($arrow instanceof \Closure) {
return;
}
if ($env->hasExtension(SandboxExtension::class) && $env->getExtension(SandboxExtension::class)->isSandboxed()) {
if ($isSandboxed) {
throw new RuntimeError(\sprintf('The callable passed to the "%s" %s must be a Closure in sandbox mode.', $thing, $type));
}
@@ -2148,7 +2172,7 @@ final class CoreExtension extends AbstractExtension
*/
public static function parseBlockFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression
{
$fakeFunction = new TwigFunction('block', fn ($name, $template = null) => null);
$fakeFunction = new TwigFunction('block', static fn ($name, $template = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args);
return new BlockReferenceExpression($args[0], $args[1] ?? null, $line);
@@ -2159,7 +2183,7 @@ final class CoreExtension extends AbstractExtension
*/
public static function parseAttributeFunction(Parser $parser, Node $fakeNode, $args, int $line): AbstractExpression
{
$fakeFunction = new TwigFunction('attribute', fn ($variable, $attribute, $arguments = null) => null);
$fakeFunction = new TwigFunction('attribute', static fn ($variable, $attribute, $arguments = null) => null);
$args = (new CallableArgumentsExtractor($fakeNode, $fakeFunction))->extractArguments($args);
/*

View File

@@ -76,7 +76,7 @@ final class EscaperExtension extends AbstractExtension
}
$this->environment = $environment;
$this->escaper = $environment->getRuntime(EscaperRuntime::class);
$this->escaper = null;
}
/**
@@ -140,16 +140,14 @@ final class EscaperExtension extends AbstractExtension
{
trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::setEscaper()" method instead (be warned that Environment is not passed anymore to the callable).', __METHOD__);
if (!isset($this->environment)) {
throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__));
}
$escaper = $this->getEscaper(__METHOD__);
$this->escapers[$strategy] = $callable;
$callable = function ($string, $charset) use ($callable) {
return $callable($this->environment, $string, $charset);
};
$this->escaper->setEscaper($strategy, $callable);
$escaper->setEscaper($strategy, $callable);
}
/**
@@ -175,11 +173,7 @@ final class EscaperExtension extends AbstractExtension
{
trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::setSafeClasses()" method instead.', __METHOD__);
if (!isset($this->escaper)) {
throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__));
}
$this->escaper->setSafeClasses($safeClasses);
$this->getEscaper(__METHOD__)->setSafeClasses($safeClasses);
}
/**
@@ -191,11 +185,20 @@ final class EscaperExtension extends AbstractExtension
{
trigger_deprecation('twig/twig', '3.10', 'The "%s()" method is deprecated, use the "Twig\Runtime\EscaperRuntime::addSafeClass()" method instead.', __METHOD__);
if (!isset($this->escaper)) {
throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', __METHOD__));
$this->getEscaper(__METHOD__)->addSafeClass($class, $strategies);
}
private function getEscaper(string $fromMethod): EscaperRuntime
{
if (isset($this->escaper)) {
return $this->escaper;
}
$this->escaper->addSafeClass($class, $strategies);
if (isset($this->environment)) {
return $this->escaper = $this->environment->getRuntime(EscaperRuntime::class);
}
throw new \LogicException(\sprintf('You must call "setEnvironment()" before calling "%s()".', $fromMethod));
}
/**

View File

@@ -28,6 +28,10 @@ final class SandboxExtension extends AbstractExtension
public function __construct(SecurityPolicyInterface $policy, $sandboxed = false, ?SourcePolicyInterface $sourcePolicy = null)
{
if (null !== $sourcePolicy) {
trigger_deprecation('twig/twig', '3.27.0', 'The "%s" interface is deprecated with no replacement, do not pass an instance to "%s".', SourcePolicyInterface::class, self::class);
}
$this->policy = $policy;
$this->sandboxedGlobally = $sandboxed;
$this->sourcePolicy = $sourcePolicy;
@@ -82,11 +86,29 @@ final class SandboxExtension extends AbstractExtension
return $this->policy;
}
public function checkSecurity($tags, $filters, $functions, ?Source $source = null): void
public function checkSecurity($tags, $filters, $functions, $tests = [], $source = null): void
{
if ($this->isSandboxed($source)) {
$this->policy->checkSecurity($tags, $filters, $functions);
// BC: previous signature was checkSecurity($tags, $filters, $functions, ?Source $source = null);
// detect a legacy call where the 4th positional argument was the Source.
if ($tests instanceof Source || (null === $tests && \func_num_args() < 5)) {
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__);
$source = $tests;
$tests = [];
}
if (!$this->isSandboxed($source)) {
return;
}
if ((new \ReflectionMethod($this->policy, 'checkSecurity'))->getNumberOfParameters() >= 4) {
$this->policy->checkSecurity($tags, $filters, $functions, $tests);
return;
}
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);
$this->policy->checkSecurity($tags, $filters, $functions);
}
public function checkMethodAllowed($obj, $method, int $lineno = -1, ?Source $source = null): void
@@ -121,14 +143,43 @@ final class SandboxExtension extends AbstractExtension
* @throws SecurityNotAllowedMethodError
*/
public function ensureToStringAllowed($obj, int $lineno = -1, ?Source $source = null)
{
return $this->doEnsureToStringAllowed($obj, $lineno, $source, new \SplObjectStorage());
}
/**
* Materialises a spread operand and runs the policy on every element.
*
* @internal
*
* @throws SecurityNotAllowedMethodError
*/
public function ensureSpreadAllowed(iterable $obj, int $lineno = -1, ?Source $source = null): array
{
$seen = new \SplObjectStorage();
if ($obj instanceof \Traversable) {
$seen[$obj] = true;
$obj = iterator_to_array($obj);
}
$this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen);
return $obj;
}
private function doEnsureToStringAllowed($obj, int $lineno, ?Source $source, \SplObjectStorage $seen)
{
if (\is_array($obj)) {
$this->ensureToStringAllowedForArray($obj, $lineno, $source);
$this->ensureToStringAllowedForArray($obj, $lineno, $source, $seen);
return $obj;
}
if ($obj instanceof \Stringable && $this->isSandboxed($source)) {
if (!$this->isSandboxed($source)) {
return $obj;
}
if ($obj instanceof \Stringable) {
try {
$this->policy->checkMethodAllowed($obj, '__toString');
} catch (SecurityNotAllowedMethodError $e) {
@@ -139,10 +190,38 @@ final class SandboxExtension extends AbstractExtension
}
}
// Elements yielded by a Traversable may be string-coerced downstream
// (e.g. by `join`/`replace`), bypassing the policy. Check them now.
if ($obj instanceof \Traversable) {
if (isset($seen[$obj])) {
return $obj;
}
$seen[$obj] = true;
// IteratorAggregate::getIterator() is idempotent, so we can walk
// the elements and return the original object: host code typed
// against a specific class (e.g. FormView) keeps working.
if ($obj instanceof \IteratorAggregate) {
foreach ($obj as $v) {
$this->doEnsureToStringAllowed($v, $lineno, $source, $seen);
}
return $obj;
}
// Single-pass Iterator/Generator: materialise to validate.
$array = iterator_to_array($obj);
$this->ensureToStringAllowedForArray($array, $lineno, $source, $seen);
if (!$obj instanceof \Stringable) {
return $array;
}
}
return $obj;
}
private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, array &$stack = []): void
private function ensureToStringAllowedForArray(array $obj, int $lineno, ?Source $source, \SplObjectStorage $seen, array &$stack = []): void
{
foreach ($obj as $k => $v) {
if (!$v) {
@@ -150,7 +229,7 @@ final class SandboxExtension extends AbstractExtension
}
if (!\is_array($v)) {
$this->ensureToStringAllowed($v, $lineno, $source);
$this->doEnsureToStringAllowed($v, $lineno, $source, $seen);
continue;
}
@@ -162,7 +241,7 @@ final class SandboxExtension extends AbstractExtension
$stack[$r->getId()] = true;
}
$this->ensureToStringAllowedForArray($v, $lineno, $source, $stack);
$this->ensureToStringAllowedForArray($v, $lineno, $source, $seen, $stack);
}
}
}

View File

@@ -29,6 +29,9 @@ final class StringLoaderExtension extends AbstractExtension
*
* {{ include(template_from_string("Hello {{ name }}")) }}
*
* Never expose `template_from_string` to untrusted template
* authors (like in a sandboxed environment). See the docs for more details.
*
* @param string|null $name An optional name of the template to be used in error messages
*
* @internal

View File

@@ -13,6 +13,7 @@
namespace Twig;
use Twig\Error\SyntaxError;
use Twig\ExpressionParser\ExpressionParsers;
/**
* @author Fabien Potencier <fabien@symfony.com>
@@ -61,6 +62,8 @@ class Lexer
public const REGEX_INLINE_COMMENT = '/#[^\n]*/A';
public const PUNCTUATION = '()[]{}?:.,|';
private const REGEX_RAW_INLINE_COMMENT = '/#[^\r\n]*/A';
private const SPECIAL_CHARS = [
'f' => "\f",
'n' => "\n",
@@ -112,7 +115,7 @@ class Lexer
'|'.
preg_quote($this->options['whitespace_line_trim'].$this->options['tag_block'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~%}[ \t\0\x0B]*
'|'.
preg_quote($this->options['tag_block'][1], '#').'\n?'. // %}\n?
preg_quote($this->options['tag_block'][1], '#').'(?:\r\n?|\n)?'. // %}(?:\r\n?|\n)?
')
}Ax',
@@ -142,7 +145,7 @@ class Lexer
'|'.
preg_quote($this->options['whitespace_line_trim'].$this->options['tag_comment'][1], '#').'['.$this->options['whitespace_line_chars'].']*'. // ~#}[ \t\0\x0B]*
'|'.
preg_quote($this->options['tag_comment'][1], '#').'\n?'. // #}\n?
preg_quote($this->options['tag_comment'][1], '#').'(?:\r\n?|\n)?'. // #}(?:\r\n?|\n)?
')
}sx',
@@ -186,7 +189,7 @@ class Lexer
$this->initialize();
$this->source = $source;
$this->code = str_replace(["\r\n", "\r"], "\n", $source->getCode());
$this->code = $source->getCode();
$this->cursor = 0;
$this->lineno = 1;
$this->end = \strlen($this->code);
@@ -240,8 +243,9 @@ class Lexer
{
// if no matches are left we return the rest of the template as simple text token
if ($this->position == \count($this->positions[0]) - 1) {
$this->pushToken(Token::TEXT_TYPE, substr($this->code, $this->cursor));
$this->cursor = $this->end;
$text = substr($this->code, $this->cursor);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
$this->moveCursor($text);
return;
}
@@ -269,15 +273,20 @@ class Lexer
$text = rtrim($text, " \t\0\x0B");
}
}
$this->pushToken(Token::TEXT_TYPE, $text);
$this->moveCursor($textContent.$position[0]);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text));
$this->moveCursor($textContent);
switch ($this->positions[1][$this->position][0]) {
case $this->options['tag_comment'][0]:
$this->moveCursor($position[0]);
$this->lexComment();
break;
case $this->options['tag_block'][0]:
$lineno = $this->lineno;
$cursor = $this->cursor;
$this->moveCursor($position[0]);
// raw data?
if (preg_match($this->regexes['lex_block_raw'], $this->code, $match, 0, $this->cursor)) {
$this->moveCursor($match[0]);
@@ -287,14 +296,17 @@ class Lexer
$this->moveCursor($match[0]);
$this->lineno = (int) $match[1];
} else {
$this->pushToken(Token::BLOCK_START_TYPE);
$this->pushToken(Token::BLOCK_START_TYPE, '', $cursor, $lineno);
$this->pushState(self::STATE_BLOCK);
$this->currentVarBlockLine = $this->lineno;
}
break;
case $this->options['tag_variable'][0]:
$this->pushToken(Token::VAR_START_TYPE);
$lineno = $this->lineno;
$cursor = $this->cursor;
$this->moveCursor($position[0]);
$this->pushToken(Token::VAR_START_TYPE, '', $cursor, $lineno);
$this->pushState(self::STATE_VAR);
$this->currentVarBlockLine = $this->lineno;
break;
@@ -304,8 +316,7 @@ class Lexer
private function lexBlock(): void
{
if (!$this->brackets && preg_match($this->regexes['lex_block'], $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::BLOCK_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::BLOCK_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
@@ -315,8 +326,7 @@ class Lexer
private function lexVar(): void
{
if (!$this->brackets && preg_match($this->regexes['lex_var'], $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::VAR_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::VAR_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
@@ -357,11 +367,11 @@ class Lexer
elseif (str_contains(self::PUNCTUATION, $this->code[$this->cursor])) {
$this->checkBrackets($this->code[$this->cursor]);
$this->pushToken(Token::PUNCTUATION_TYPE, $this->code[$this->cursor]);
++$this->cursor;
$this->moveCursor($this->code[$this->cursor]);
}
// strings
elseif (preg_match(self::REGEX_STRING, $this->code, $match, 0, $this->cursor)) {
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes(substr($match[0], 1, -1), substr($match[0], 0, 1)));
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines(substr($match[0], 1, -1)), substr($match[0], 0, 1)));
$this->moveCursor($match[0]);
}
// opening double quoted string
@@ -371,12 +381,12 @@ class Lexer
$this->moveCursor($match[0]);
}
// inline comment
elseif (preg_match(self::REGEX_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) {
elseif (preg_match(self::REGEX_RAW_INLINE_COMMENT, $this->code, $match, 0, $this->cursor)) {
$this->moveCursor($match[0]);
}
// unlexable
else {
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
}
@@ -425,7 +435,7 @@ class Lexer
while ($i + 1 < $length && ctype_digit($str[$i + 1]) && $str[$i + 1] < '8' && \strlen($octal) < 3) {
$octal .= $str[++$i];
}
$result .= \chr(octdec($octal));
$result .= \chr(octdec($octal) % 256);
} else {
trigger_deprecation('twig/twig', '3.12', 'Character "%s" should not be escaped; the "\" character is ignored in Twig 3 but will not be in Twig 4. Please remove the extra "\" character at position %d in "%s" at line %d.', $nextChar, $i + 1, $this->source->getName(), $this->lineno);
$result .= $nextChar;
@@ -443,6 +453,7 @@ class Lexer
throw new SyntaxError('Unexpected end of file: Unclosed "verbatim" block.', $this->lineno, $this->source);
}
$offset = $this->cursor;
$text = substr($this->code, $this->cursor, $match[0][1] - $this->cursor);
$this->moveCursor($text.$match[0][0]);
@@ -458,7 +469,7 @@ class Lexer
}
}
$this->pushToken(Token::TEXT_TYPE, $text);
$this->pushToken(Token::TEXT_TYPE, $this->normalizeNewlines($text), $offset);
}
private function lexComment(): void
@@ -478,7 +489,7 @@ class Lexer
$this->moveCursor($match[0]);
$this->pushState(self::STATE_INTERPOLATION);
} elseif (preg_match(self::REGEX_DQ_STRING_PART, $this->code, $match, 0, $this->cursor) && '' !== $match[0]) {
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($match[0], '"'));
$this->pushToken(Token::STRING_TYPE, $this->stripcslashes($this->normalizeNewlines($match[0]), '"'));
$this->moveCursor($match[0]);
} elseif (preg_match(self::REGEX_DQ_STRING_DELIM, $this->code, $match, 0, $this->cursor)) {
[$expect, $lineno] = array_pop($this->brackets);
@@ -487,10 +498,10 @@ class Lexer
}
$this->popState();
++$this->cursor;
$this->moveCursor($match[0]);
} else {
// unlexable
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected character "%s".', $this->code[$this->cursor]), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
}
@@ -499,35 +510,54 @@ class Lexer
$bracket = end($this->brackets);
if ($this->options['interpolation'][0] === $bracket[0] && preg_match($this->regexes['interpolation_end'], $this->code, $match, 0, $this->cursor)) {
array_pop($this->brackets);
$this->pushToken(Token::INTERPOLATION_END_TYPE);
$this->moveCursor($match[0]);
$this->pushClosingToken(Token::INTERPOLATION_END_TYPE, $match[0]);
$this->popState();
} else {
$this->lexExpression();
}
}
private function pushToken($type, $value = ''): void
private function pushToken($type, $value = '', ?int $offset = null, ?int $lineno = null): void
{
// do not push empty text tokens
if (Token::TEXT_TYPE === $type && '' === $value) {
return;
}
$this->tokens[] = new Token($type, $value, $this->lineno);
// by default the token starts at the current cursor; callers that
// emit a token after consuming it must pass an explicit offset
$this->tokens[] = new Token($type, $value, $lineno ?? $this->lineno, $offset ?? $this->cursor);
}
private function moveCursor($text): void
{
$this->cursor += \strlen($text);
$this->lineno += substr_count($text, "\n");
// count "\r\n" and "\r" as a single newline without allocating a
// normalized copy when the chunk has no carriage return (common case)
$this->lineno += str_contains($text, "\r") ? substr_count($this->normalizeNewlines($text), "\n") : substr_count($text, "\n");
}
private function normalizeNewlines(string $text): string
{
return str_replace(["\r\n", "\r"], "\n", $text);
}
private function pushClosingToken(int $type, string $match): void
{
$leadingWhitespaceLength = \strlen($match) - \strlen(ltrim($match));
if ($leadingWhitespaceLength) {
$this->moveCursor(substr($match, 0, $leadingWhitespaceLength));
}
$this->pushToken($type);
$this->moveCursor(substr($match, $leadingWhitespaceLength));
}
private function getOperatorRegex(): string
{
$expressionParsers = [];
foreach ($this->env->getExpressionParsers() as $expressionParser) {
$expressionParsers = array_merge($expressionParsers, [$expressionParser->getName()], $expressionParser->getAliases());
$expressionParsers = array_merge($expressionParsers, ExpressionParsers::getOperatorTokensFor($expressionParser));
}
$expressionParsers = array_combine($expressionParsers, array_map('strlen', $expressionParsers));
@@ -544,7 +574,7 @@ class Lexer
// an operator that begins with a character must not have a dot or pipe before
if (ctype_alpha($expressionParser[0])) {
$r = '(?<![\.\|])'.$r;
$r = '(?<![\.\|]\s|.[\.\|])'.$r;
}
// an operator with a space can be any amount of whitespaces
@@ -579,7 +609,7 @@ class Lexer
} elseif (\in_array($code, $this->closingBrackets, true)) {
// closing bracket
if (!$this->brackets) {
throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source);
throw new SyntaxError(\sprintf('Unexpected "%s".', $code), $this->lineno, $this->source, columnno: $this->source->getColumn($this->cursor));
}
[$expect, $lineno] = array_pop($this->brackets);

View File

@@ -14,6 +14,16 @@ namespace Twig;
/**
* 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.
*
* This class is considered final as of Twig 3.28 and will be final in Twig
* 4.0.
*
* @final since Twig 3.28
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class Markup implements \Countable, \JsonSerializable, \Stringable

View File

@@ -27,7 +27,6 @@ class CheckSecurityCallNode extends Node
{
$compiler
->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n")
->write("\$this->checkSecurity();\n")
;
}
}

View File

@@ -23,17 +23,24 @@ class CheckSecurityNode extends Node
private $usedFilters;
private $usedTags;
private $usedFunctions;
private $usedTests;
/**
* @param array<string, int> $usedFilters
* @param array<string, int> $usedTags
* @param array<string, int> $usedFunctions
* @param array<string, int> $usedTests
*/
public function __construct(array $usedFilters, array $usedTags, array $usedFunctions)
public function __construct(array $usedFilters, array $usedTags, array $usedFunctions, array $usedTests = [])
{
if (\func_num_args() < 4) {
trigger_deprecation('twig/twig', '3.28', 'Not passing the "$usedTests" argument to "%s::__construct()" is deprecated; it will be required in 4.0.', static::class);
}
$this->usedFilters = $usedFilters;
$this->usedTags = $usedTags;
$this->usedFunctions = $usedFunctions;
$this->usedTests = $usedTests;
parent::__construct();
}
@@ -41,20 +48,33 @@ class CheckSecurityNode extends Node
public function compile(Compiler $compiler): void
{
$compiler
->write("\n")
->write("public function ensureSecurityChecked(): void\n")
->write("{\n")
->indent()
->write("if (\$this->sandbox->isSandboxed(\$this->source)) {\n")
->indent()
->write("\$this->checkSecurity();\n")
->outdent()
->write("}\n")
->outdent()
->write("}\n")
->write("\n")
->write("public function checkSecurity()\n")
->write("{\n")
->indent()
->write('static $tags = ')->repr(array_filter($this->usedTags))->raw(";\n")
->write('static $filters = ')->repr(array_filter($this->usedFilters))->raw(";\n")
->write('static $functions = ')->repr(array_filter($this->usedFunctions))->raw(";\n\n")
->write('static $functions = ')->repr(array_filter($this->usedFunctions))->raw(";\n")
->write('static $tests = ')->repr(array_filter($this->usedTests))->raw(";\n\n")
->write("try {\n")
->indent()
->write("\$this->sandbox->checkSecurity(\n")
->indent()
->write(!$this->usedTags ? "[],\n" : "['".implode("', '", array_keys($this->usedTags))."'],\n")
->write(!$this->usedFilters ? "[],\n" : "['".implode("', '", array_keys($this->usedFilters))."'],\n")
->write(!$this->usedFunctions ? "[],\n" : "['".implode("', '", array_keys($this->usedFunctions))."'],\n")
->write('')->repr(array_keys($this->usedTags))->raw(",\n")
->write('')->repr(array_keys($this->usedFilters))->raw(",\n")
->write('')->repr(array_keys($this->usedFunctions))->raw(",\n")
->write('')->repr(array_keys($this->usedTests))->raw(",\n")
->write("\$this->source\n")
->outdent()
->write(");\n")
@@ -74,6 +94,10 @@ class CheckSecurityNode extends Node
->indent()
->write("\$e->setTemplateLine(\$functions[\$e->getFunctionName()]);\n")
->outdent()
->write("} elseif (\$e instanceof SecurityNotAllowedTestError && isset(\$tests[\$e->getTestName()])) {\n")
->indent()
->write("\$e->setTemplateLine(\$tests[\$e->getTestName()]);\n")
->outdent()
->write("}\n\n")
->write("throw \$e;\n")
->outdent()

View File

@@ -28,16 +28,17 @@ use Twig\Node\Expression\AbstractExpression;
#[YieldReady]
class CheckToStringNode extends AbstractExpression
{
public function __construct(AbstractExpression $expr)
public function __construct(AbstractExpression $expr, bool $spread = false)
{
parent::__construct(['expr' => $expr], [], $expr->getTemplateLine());
parent::__construct(['expr' => $expr], ['spread' => $spread], $expr->getTemplateLine());
}
public function compile(Compiler $compiler): void
{
$expr = $this->getNode('expr');
$method = $this->getAttribute('spread') ? 'ensureSpreadAllowed' : 'ensureToStringAllowed';
$compiler
->raw('$this->sandbox->ensureToStringAllowed(')
->raw('$this->sandbox->'.$method.'(')
->subcompile($expr)
->raw(', ')
->repr($expr->getTemplateLine())

View File

@@ -0,0 +1,40 @@
<?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\Node;
use Twig\Node\Expression\OperatorEscapeInterface;
/**
* Implemented by nodes that implicitly coerce one or more of their child
* nodes to string at runtime (PHP string casts, regex matching, comparisons,
* range bounds, template-name resolution by the loader, etc.).
*
* The sandbox node visitor wraps the listed children with a CheckToStringNode
* so that an implicit `__toString()` call goes through the sandbox policy
* check, independently of where this node's result is used.
*
* This is distinct from {@see OperatorEscapeInterface}, which describes
* operands whose value becomes this expression's value (passthrough operators
* like ternaries) and is consumed by the escaper.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
interface CoercesChildrenToStringInterface
{
/**
* Returns the names of the child nodes that will be coerced to
* string when this node is evaluated.
*
* @return string[]
*/
public function getStringCoercedChildNames(): array;
}

View File

@@ -0,0 +1,30 @@
<?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\Node;
use Twig\Attribute\YieldReady;
/**
* Represents a node that has global side effects but does not generate template code.
*
* Such nodes must be at the root level of the body of a template.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class ConfigNode extends Node
{
public function __construct(int $lineno)
{
parent::__construct([], [], $lineno);
}
}

View File

@@ -22,7 +22,7 @@ use Twig\Node\Expression\ConstantExpression;
* @author Yonel Ceruto <yonelceruto@gmail.com>
*/
#[YieldReady]
class DeprecatedNode extends Node
class DeprecatedNode extends Node implements CoercesChildrenToStringInterface
{
public function __construct(AbstractExpression $expr, int $lineno)
{
@@ -70,4 +70,18 @@ class DeprecatedNode extends Node
->raw(");\n")
;
}
public function getStringCoercedChildNames(): array
{
// the message is concatenated with `.`, and `package` / `version` are typed `string` on trigger_deprecation()
$names = ['expr'];
if ($this->hasNode('package')) {
$names[] = 'package';
}
if ($this->hasNode('version')) {
$names[] = 'version';
}
return $names;
}
}

View File

@@ -13,11 +13,11 @@ namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Error\SyntaxError;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Unary\StringCastUnary;
use Twig\Node\Expression\Variable\ContextVariable;
class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface
class ArrayExpression extends AbstractExpression implements SupportDefinedTestInterface, ReturnArrayInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestTrait;
@@ -95,6 +95,24 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn
array_push($this->nodes, $key, $value);
}
public function getStringCoercedChildNames(): array
{
// dynamic mapping keys (computed at runtime) are coerced to string;
// static keys (constants or sequence indexes) are emitted as PHP
// literals by compile() and never trigger a __toString() call
$names = [];
foreach (array_chunk($this->nodes, 2) as $i => $pair) {
$key = $pair[0];
if ($key instanceof ConstantExpression || $key instanceof TempNameExpression) {
continue;
}
$names[] = (string) ($i * 2);
}
return $names;
}
public function compile(Compiler $compiler): void
{
if ($this->definedTest) {
@@ -118,13 +136,15 @@ class ArrayExpression extends AbstractExpression implements SupportDefinedTestIn
}
$key = null;
if ($pair['key'] instanceof ContextVariable) {
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
} elseif ($pair['key'] instanceof TempNameExpression) {
if ($pair['key'] instanceof TempNameExpression) {
$key = $pair['key']->getAttribute('name');
$pair['key'] = new ConstantExpression($key, $pair['key']->getTemplateLine());
} elseif ($pair['key'] instanceof ConstantExpression) {
$key = $pair['key']->getAttribute('value');
} else {
// dynamic key: cast to string so PHP accepts it as an array offset
// (the sandbox visitor has already wrapped it with a __toString policy check)
$pair['key'] = new StringCastUnary($pair['key'], $pair['key']->getTemplateLine());
}
if ($key !== $i) {

View File

@@ -26,14 +26,14 @@ class ArrowFunctionExpression extends AbstractExpression
{
public function __construct(AbstractExpression $expr, Node $names, $lineno)
{
if (!$names instanceof ListExpression && !$names instanceof ContextVariable) {
throw new SyntaxError('The arrow function argument must be a list of variables or a single variable.', $names->getTemplateLine(), $names->getSourceContext());
}
if ($names instanceof ContextVariable) {
$names = new ListExpression([new AssignContextVariable($names->getAttribute('name'), $names->getTemplateLine())], $lineno);
}
if (!$names instanceof ListExpression) {
throw new SyntaxError('The arrow function argument must be a list of variables or a single variable.', $names->getTemplateLine(), $names->getSourceContext());
}
parent::__construct(['expr' => $expr, 'names' => $names], [], $lineno);
}

View File

@@ -14,9 +14,16 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\Expression\ReturnBoolInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\Node\Node;
class AndBinary extends AbstractBinary implements ReturnBoolInterface
{
public function __construct(Node $left, Node $right, int $lineno)
{
parent::__construct(TrueTest::wrap($left), TrueTest::wrap($right), $lineno);
}
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('&&');

View File

@@ -13,12 +13,18 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnStringInterface;
class ConcatBinary extends AbstractBinary implements ReturnStringInterface
class ConcatBinary extends AbstractBinary implements ReturnStringInterface, CoercesChildrenToStringInterface
{
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('.');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -14,6 +14,7 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\OperatorEscapeInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\Node\Node;
final class ElvisBinary extends AbstractBinary implements OperatorEscapeInterface
@@ -26,7 +27,7 @@ final class ElvisBinary extends AbstractBinary implements OperatorEscapeInterfac
{
parent::__construct($left, $right, $lineno);
$this->setNode('test', clone $left);
$this->setNode('test', TrueTest::wrap(clone $left));
$left->setAttribute('always_defined', true);
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class EqualBinary extends AbstractBinary implements ReturnBoolInterface
class EqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class EqualBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('==');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class GreaterBinary extends AbstractBinary implements ReturnBoolInterface
class GreaterBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class GreaterBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('>');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface
class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class GreaterEqualBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('>=');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -23,7 +23,7 @@ class HasEveryBinary extends AbstractBinary implements ReturnBoolInterface
->subcompile($this->getNode('left'))
->raw(', ')
->subcompile($this->getNode('right'))
->raw(')')
->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source))')
;
}

View File

@@ -23,7 +23,7 @@ class HasSomeBinary extends AbstractBinary implements ReturnBoolInterface
->subcompile($this->getNode('left'))
->raw(', ')
->subcompile($this->getNode('right'))
->raw(')')
->raw(', $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source))')
;
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class InBinary extends AbstractBinary implements ReturnBoolInterface
class InBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -31,4 +32,9 @@ class InBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('in');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class LessBinary extends AbstractBinary implements ReturnBoolInterface
class LessBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class LessBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('<');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface
class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class LessEqualBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('<=');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -13,14 +13,23 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Error\SyntaxError;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\ReturnBoolInterface;
use Twig\Node\Node;
class MatchesBinary extends AbstractBinary implements ReturnBoolInterface
class MatchesBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function __construct(Node $left, Node $right, int $lineno)
{
if (!$left instanceof AbstractExpression) {
trigger_deprecation('twig/twig', '3.24', 'Passing a "%s" instance to "%s()" first argument is deprecated, pass an "AbstractExpression" instance instead.', $left::class, __METHOD__);
}
if (!$right instanceof AbstractExpression) {
trigger_deprecation('twig/twig', '3.24', 'Passing a "%s" instance to "%s()" second argument is deprecated, pass an "AbstractExpression" instance instead.', $right::class, __METHOD__);
}
if ($right instanceof ConstantExpression) {
$regexp = $right->getAttribute('value');
set_error_handler(static fn ($t, $m) => throw new SyntaxError(\sprintf('Regexp "%s" passed to "matches" is not valid: %s.', $regexp, substr($m, 14)), $lineno));
@@ -49,4 +58,9 @@ class MatchesBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface
class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -37,4 +38,9 @@ class NotEqualBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('!=');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnBoolInterface;
class NotInBinary extends AbstractBinary implements ReturnBoolInterface
class NotInBinary extends AbstractBinary implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -31,4 +32,9 @@ class NotInBinary extends AbstractBinary implements ReturnBoolInterface
{
return $compiler->raw('not in');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -32,12 +32,12 @@ final class NullCoalesceBinary extends AbstractBinary implements OperatorEscapeI
{
parent::__construct($left, $right, $lineno);
$test = new DefinedTest(clone $left, new TwigTest('defined'), new EmptyNode(), $left->getTemplateLine());
$test = new DefinedTest(clone $left, new TwigTest('defined', null, ['always_allowed_in_sandbox' => true]), new EmptyNode(), $left->getTemplateLine());
// for "block()", we don't need the null test as the return value is always a string
if (!$left instanceof BlockReferenceExpression) {
$test = new AndBinary(
$test,
new NotUnary(new NullTest($left, new TwigTest('null'), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()),
new NotUnary(new NullTest($left, new TwigTest('null', null, ['always_allowed_in_sandbox' => true]), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()),
$left->getTemplateLine(),
);
}

View File

@@ -13,6 +13,7 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Error\SyntaxError;
use Twig\Extension\SandboxExtension;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Variable\ContextVariable;
@@ -23,7 +24,8 @@ use Twig\Node\Node;
*/
class ObjectDestructuringSetBinary extends AbstractBinary
{
private array $properties = [];
/** @var list<array{property: string, variable: string}> */
private array $mappings = [];
/**
* @param ArrayExpression $left The array expression containing object/mapping destructuring properties
@@ -38,7 +40,11 @@ class ObjectDestructuringSetBinary extends AbstractBinary
if (!$pair['value'] instanceof ContextVariable) {
throw new SyntaxError(\sprintf('Cannot assign to "%s", only variables can be assigned in object/mapping destructuring.', $pair['value']::class), $lineno);
}
$this->properties[] = $pair['value']->getAttribute('name');
$this->mappings[] = [
'property' => $pair['key']->getAttribute('value'),
'variable' => $pair['value']->getAttribute('name'),
];
}
parent::__construct($left, $right, $lineno);
@@ -48,18 +54,18 @@ class ObjectDestructuringSetBinary extends AbstractBinary
{
$compiler->addDebugInfo($this);
$compiler->raw('[');
foreach ($this->properties as $i => $property) {
foreach ($this->mappings as $i => $mapping) {
if ($i) {
$compiler->raw(', ');
}
$compiler->raw('$context[')->repr($property)->raw(']');
$compiler->raw('$context[')->repr($mapping['variable'])->raw(']');
}
$compiler->raw('] = [');
foreach ($this->properties as $i => $property) {
foreach ($this->mappings as $i => $mapping) {
if ($i) {
$compiler->raw(', ');
}
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($property)->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($mapping['property'])->raw(', [], \\Twig\\Template::ANY_CALL, false, false, ')->repr($compiler->getEnvironment()->hasExtension(SandboxExtension::class))->raw(', ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
}
$compiler->raw(']');
}

View File

@@ -14,9 +14,16 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\Expression\ReturnBoolInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\Node\Node;
class OrBinary extends AbstractBinary implements ReturnBoolInterface
{
public function __construct(Node $left, Node $right, int $lineno)
{
parent::__construct(TrueTest::wrap($left), TrueTest::wrap($right), $lineno);
}
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('||');

View File

@@ -12,9 +12,10 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnArrayInterface;
class RangeBinary extends AbstractBinary implements ReturnArrayInterface
class RangeBinary extends AbstractBinary implements ReturnArrayInterface, CoercesChildrenToStringInterface
{
public function compile(Compiler $compiler): void
{
@@ -31,4 +32,9 @@ class RangeBinary extends AbstractBinary implements ReturnArrayInterface
{
return $compiler->raw('..');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -12,12 +12,18 @@
namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ReturnNumberInterface;
class SpaceshipBinary extends AbstractBinary implements ReturnNumberInterface
class SpaceshipBinary extends AbstractBinary implements ReturnNumberInterface, CoercesChildrenToStringInterface
{
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('<=>');
}
public function getStringCoercedChildNames(): array
{
return ['left', 'right'];
}
}

View File

@@ -14,9 +14,16 @@ namespace Twig\Node\Expression\Binary;
use Twig\Compiler;
use Twig\Node\Expression\ReturnBoolInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\Node\Node;
class XorBinary extends AbstractBinary implements ReturnBoolInterface
{
public function __construct(Node $left, Node $right, int $lineno)
{
parent::__construct(TrueTest::wrap($left), TrueTest::wrap($right), $lineno);
}
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('xor');

View File

@@ -13,6 +13,7 @@
namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Node;
/**
@@ -20,7 +21,7 @@ use Twig\Node\Node;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface
class BlockReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestDeprecationTrait;
use SupportDefinedTestTrait;
@@ -60,6 +61,12 @@ class BlockReferenceExpression extends AbstractExpression implements SupportDefi
}
}
public function getStringCoercedChildNames(): array
{
// the template expression is resolved through the loader, which coerces it to a string
return $this->hasNode('template') ? ['template'] : [];
}
private function compileTemplateCall(Compiler $compiler, string $method): Compiler
{
if (!$this->hasNode('template')) {

View File

@@ -24,7 +24,7 @@ use Twig\Util\ReflectionCallable;
abstract class CallExpression extends AbstractExpression
{
private $reflector = null;
private $reflector;
/**
* @return void
@@ -100,6 +100,14 @@ abstract class CallExpression extends AbstractExpression
$first = false;
}
if (self::needsIsSandboxed($twigCallable)) {
if (!$first) {
$compiler->raw(', ');
}
$compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source)');
$first = false;
}
foreach ($twigCallable->getArguments() as $argument) {
if (!$first) {
$compiler->raw(', ');
@@ -213,9 +221,8 @@ abstract class CallExpression extends AbstractExpression
} elseif ($callableParameter->isOptional()) {
if (!$parameters) {
break;
} else {
$missingArguments[] = $name;
}
$missingArguments[] = $name;
} else {
throw new SyntaxError(\sprintf('Value for argument "%s" is required for %s "%s".', $name, $callType, $callName), $this->getTemplateLine(), $this->getSourceContext());
}
@@ -275,25 +282,9 @@ abstract class CallExpression extends AbstractExpression
{
$twigCallable = $this->getAttribute('twig_callable');
$rc = $this->reflectCallable($twigCallable);
$r = $rc->getReflector();
$callableName = $rc->getName();
$parameters = $r->getParameters();
if ($this->hasNode('node')) {
array_shift($parameters);
}
if ($twigCallable->needsCharset()) {
array_shift($parameters);
}
if ($twigCallable->needsEnvironment()) {
array_shift($parameters);
}
if ($twigCallable->needsContext()) {
array_shift($parameters);
}
foreach ($twigCallable->getArguments() as $argument) {
array_shift($parameters);
}
$parameters = $rc->getTwigParameters($this->hasNode('node'));
$isPhpVariadic = false;
if ($isVariadic) {
@@ -321,6 +312,22 @@ abstract class CallExpression extends AbstractExpression
return $this->reflector;
}
/**
* @internal
*
* To be removed in 4.0 and replaced by $twigCallable->needsIsSandboxed().
*/
public static function needsIsSandboxed(TwigCallableInterface $twigCallable): bool
{
if (method_exists($twigCallable, 'needsIsSandboxed')) {
return $twigCallable->needsIsSandboxed();
}
trigger_deprecation('twig/twig', '3.25', 'Not implementing the "needsIsSandboxed()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $twigCallable::class, TwigCallableInterface::class);
return false;
}
/**
* Overrides the Twig callable based on attributes (as potentially, attributes changed between the creation and the compilation of the node).
*
@@ -335,6 +342,7 @@ abstract class CallExpression extends AbstractExpression
$this->getAttribute('name'),
$this->hasAttribute('callable') ? $this->getAttribute('callable') : $current->getCallable(),
[
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
@@ -345,6 +353,7 @@ abstract class CallExpression extends AbstractExpression
'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),
@@ -355,6 +364,7 @@ abstract class CallExpression extends AbstractExpression
'needs_environment' => $this->hasAttribute('needs_environment') ? $this->getAttribute('needs_environment') : $current->needsEnvironment(),
'needs_context' => $this->hasAttribute('needs_context') ? $this->getAttribute('needs_context') : $current->needsContext(),
'needs_charset' => $this->hasAttribute('needs_charset') ? $this->getAttribute('needs_charset') : $current->needsCharset(),
'needs_is_sandboxed' => $this->hasAttribute('needs_is_sandboxed') ? $this->getAttribute('needs_is_sandboxed') : self::needsIsSandboxed($current),
'is_variadic' => $this->hasAttribute('is_variadic') ? $this->getAttribute('is_variadic') : $current->isVariadic(),
],
))->withDynamicArguments($this->getAttribute('name'), $this->hasAttribute('dynamic_name') ? $this->getAttribute('dynamic_name') : $current->getDynamicName(), $this->hasAttribute('arguments') ? $this->getAttribute('arguments') : $current->getArguments()),

View File

@@ -54,7 +54,7 @@ class DefaultFilter extends FilterExpression
}
if ('default' === $name && ($node instanceof ContextVariable || $node instanceof GetAttrExpression)) {
$test = new DefinedTest(clone $node, new TwigTest('defined'), new EmptyNode(), $node->getTemplateLine());
$test = new DefinedTest(clone $node, new TwigTest('defined', null, ['always_allowed_in_sandbox' => true]), new EmptyNode(), $node->getTemplateLine());
$false = \count($arguments) ? $arguments->getNode('0') : new ConstantExpression('', $node->getTemplateLine());
$node = new ConditionalTernary($test, $default, $false, $node->getTemplateLine());

View File

@@ -14,11 +14,12 @@ namespace Twig\Node\Expression;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\NameDeprecation;
use Twig\Node\Node;
use Twig\TwigFilter;
class FilterExpression extends CallExpression
class FilterExpression extends CallExpression implements CoercesChildrenToStringInterface
{
/**
* @param AbstractExpression $node
@@ -77,4 +78,10 @@ class FilterExpression extends CallExpression
$this->compileCallable($compiler);
}
public function getStringCoercedChildNames(): array
{
// a filter may coerce its input and arguments to string (e.g. `upper`, `replace`)
return ['node', 'arguments'];
}
}

View File

@@ -13,11 +13,12 @@ namespace Twig\Node\Expression;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\NameDeprecation;
use Twig\Node\Node;
use Twig\TwigFunction;
class FunctionExpression extends CallExpression implements SupportDefinedTestInterface
class FunctionExpression extends CallExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestDeprecationTrait;
use SupportDefinedTestTrait;
@@ -78,4 +79,10 @@ class FunctionExpression extends CallExpression implements SupportDefinedTestInt
$this->compileCallable($compiler);
}
public function getStringCoercedChildNames(): array
{
// a function may coerce its arguments to string (the host PHP code is opaque to Twig)
return ['arguments'];
}
}

View File

@@ -14,10 +14,11 @@ namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Extension\SandboxExtension;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Template;
class GetAttrExpression extends AbstractExpression implements SupportDefinedTestInterface
class GetAttrExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestDeprecationTrait;
use SupportDefinedTestTrait;
@@ -36,7 +37,7 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
trigger_deprecation('twig/twig', '3.15', \sprintf('Not passing a "%s" instance as the "arguments" argument of the "%s" constructor is deprecated ("%s" given).', ArrayExpression::class, static::class, $arguments::class));
}
parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe], $lineno);
parent::__construct($nodes, ['type' => $type, 'ignore_strict_check' => false, 'optimizable' => !$nullSafe, 'null_safe' => $nullSafe, 'is_short_circuited' => false, 'var_name' => null], $lineno);
}
public function enableDefinedTest(): void
@@ -50,7 +51,6 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$env = $compiler->getEnvironment();
$arrayAccessSandbox = false;
$nullSafe = $this->getAttribute('null_safe');
$objectVar = null;
// optimize array calls
if (
@@ -73,9 +73,9 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
->raw(' instanceof ArrayAccess ? (')
->raw($var)
->raw('[')
->subcompile($this->getNode('attribute'))
->raw('] ?? null) : null)')
;
$this->compileArrayKey($compiler);
$compiler->raw('] ?? null) : null)');
return;
}
@@ -90,27 +90,41 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
->raw(', CoreExtension::ARRAY_LIKE_CLASSES, true) ? (')
->raw($var)
->raw('[')
->subcompile($this->getNode('attribute'))
->raw('] ?? null) : ')
;
$this->compileArrayKey($compiler);
$compiler->raw('] ?? null) : ');
}
if ($this->getAttribute('ignore_strict_check')) {
$this->getNode('node')->setAttribute('ignore_strict_check', true);
}
if ($nullSafe) {
$objectVar = '$'.$compiler->getVarName();
if (null === $nullSafeNode = $nullSafe ? $this : null) {
$node = $this->getNode('node');
while ($node instanceof self) {
if ($node->getAttribute('null_safe')) {
$nullSafeNode = $node;
break;
}
$node = $node->getNode('node');
}
}
$isShortCircuited = false;
if (null !== $nullSafeNode && !$nullSafeNode->isShortCircuited()) {
$compiler
->raw('((null === ('.$objectVar.' = ')
->subcompile($this->getNode('node'))
->raw('((null === ('.$nullSafeNode->getVarName($compiler).' = ')
->subcompile($nullSafeNode->getNode('node'))
->raw(')) ? null : ');
$nullSafeNode->markAsShortCircuited();
$isShortCircuited = true;
}
$compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ');
if ($nullSafe) {
$compiler->raw($objectVar);
$compiler->raw($this->getVarName($compiler));
} else {
$compiler->subcompile($this->getNode('node'));
}
@@ -139,11 +153,50 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$compiler->raw(')');
}
if ($nullSafe) {
if ($isShortCircuited) {
$compiler->raw(')');
}
}
public function getStringCoercedChildNames(): array
{
$names = [];
// the host PHP method may coerce any argument to string
if ($this->hasNode('arguments')) {
$names[] = 'arguments';
}
// compileArrayKey() coerces a Stringable key; expose it so the sandbox checks __toString()
if (Template::ARRAY_CALL === $this->getAttribute('type')) {
$names[] = 'attribute';
}
return $names;
}
/**
* Coerces a Stringable array key to string so the optimized path matches
* CoreExtension::getAttribute(); scalars are left to PHP's native offset coercion.
*/
private function compileArrayKey(Compiler $compiler): void
{
$attribute = $this->getNode('attribute');
if ($attribute instanceof ConstantExpression) {
$compiler->subcompile($attribute);
return;
}
$key = '$'.$compiler->getVarName();
$compiler
->raw('(('.$key.' = ')
->subcompile($attribute)
->raw(') instanceof \Stringable ? (string) '.$key.' : '.$key.')')
;
}
private function changeIgnoreStrictCheck(self $node): void
{
$node->setAttribute('optimizable', false);
@@ -153,4 +206,23 @@ class GetAttrExpression extends AbstractExpression implements SupportDefinedTest
$this->changeIgnoreStrictCheck($node->getNode('node'));
}
}
private function markAsShortCircuited(): void
{
$this->setAttribute('is_short_circuited', true);
}
private function isShortCircuited(): bool
{
return $this->getAttribute('is_short_circuited');
}
private function getVarName(Compiler $compiler): string
{
if (null === $this->getAttribute('var_name')) {
$this->setAttribute('var_name', $compiler->getVarName());
}
return '$'.$this->getAttribute('var_name');
}
}

View File

@@ -12,12 +12,12 @@
namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Expression\Variable\AssignContextVariable;
class ListExpression extends AbstractExpression
{
/**
* @param array<ContextVariable> $items
* @param array<AssignContextVariable> $items
*/
public function __construct(array $items, int $lineno)
{

View File

@@ -12,6 +12,7 @@
namespace Twig\Node\Expression;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\Variable\TemplateVariable;
/**
@@ -19,18 +20,55 @@ use Twig\Node\Expression\Variable\TemplateVariable;
*
* @author Fabien Potencier <fabien@symfony.com>
*/
class MacroReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface
class MacroReferenceExpression extends AbstractExpression implements SupportDefinedTestInterface, CoercesChildrenToStringInterface
{
use SupportDefinedTestDeprecationTrait;
use SupportDefinedTestTrait;
public function __construct(TemplateVariable $template, string $name, AbstractExpression $arguments, int $lineno)
/**
* @param string|AbstractExpression $name A static macro method name (e.g. "macro_foo") or, for a dynamic
* call, an expression resolving to the macro name (without the
* "macro_" prefix, which is added at runtime)
*/
public function __construct(TemplateVariable $template, string|AbstractExpression $name, AbstractExpression $arguments, int $lineno)
{
parent::__construct(['template' => $template, 'arguments' => $arguments], ['name' => $name], $lineno);
$nodes = ['template' => $template, 'arguments' => $arguments];
$attributes = ['name' => null];
if (\is_string($name)) {
// The name is emitted as raw PHP in compile() via "->{$name}(...)",
// so it must be a valid PHP method identifier. Reject anything else
// as a defense-in-depth against accidental PHP code injection from
// a caller that forgot to validate user-controlled input.
if (!preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $name)) {
throw new \LogicException(\sprintf('Macro name "%s" is not a valid PHP identifier.', $name));
}
$attributes['name'] = $name;
} else {
$nodes['name'] = $name;
}
parent::__construct($nodes, $attributes, $lineno);
}
public function __clone()
{
// The template node must not be deep-cloned because its name is
// lazily generated during compilation and must stay in sync with
// the AssignTemplateVariable that populates the $macros array.
$template = $this->nodes['template'];
parent::__clone();
$this->nodes['template'] = $template;
}
public function compile(Compiler $compiler): void
{
if ($this->hasNode('name')) {
$this->compileDynamic($compiler);
return;
}
if ($this->definedTest) {
$compiler
->subcompile($this->getNode('template'))
@@ -56,4 +94,43 @@ class MacroReferenceExpression extends AbstractExpression implements SupportDefi
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// Dynamic macro names are prefixed via PHP string concatenation at runtime.
return $this->hasNode('name') ? ['name'] : [];
}
private function compileDynamic(Compiler $compiler): void
{
// The macro method name is resolved at runtime from a context value;
// prefixing it with "macro_" constrains the dynamic method call to the
// template's macro methods only, and getTemplateForMacro()/hasMacro()
// validate that the method actually exists.
$var = $compiler->getVarName();
if ($this->definedTest) {
$compiler
->subcompile($this->getNode('template'))
->raw('->hasMacro(\'macro_\'.')
->subcompile($this->getNode('name'))
->raw(', $context)')
;
return;
}
$compiler
->subcompile($this->getNode('template'))
->raw(\sprintf('->getTemplateForMacro($%s = \'macro_\'.', $var))
->subcompile($this->getNode('name'))
->raw(', $context, ')
->repr($this->getTemplateLine())
->raw(', $this->getSourceContext())')
->raw(\sprintf('->{$%s}', $var))
->raw('(...')
->subcompile($this->getNode('arguments'))
->raw(')')
;
}
}

View File

@@ -39,12 +39,12 @@ class NullCoalesceExpression extends ConditionalExpression
trigger_deprecation('twig/twig', '3.15', 'Not passing a "%s" instance to the "right" argument of "%s" is deprecated ("%s" given).', AbstractExpression::class, static::class, $right::class);
}
$test = new DefinedTest(clone $left, new TwigTest('defined'), new EmptyNode(), $left->getTemplateLine());
$test = new DefinedTest(clone $left, new TwigTest('defined', null, ['always_allowed_in_sandbox' => true]), new EmptyNode(), $left->getTemplateLine());
// for "block()", we don't need the null test as the return value is always a string
if (!$left instanceof BlockReferenceExpression) {
$test = new AndBinary(
$test,
new NotUnary(new NullTest($left, new TwigTest('null'), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()),
new NotUnary(new NullTest($left, new TwigTest('null', null, ['always_allowed_in_sandbox' => true]), new EmptyNode(), $left->getTemplateLine()), $left->getTemplateLine()),
$left->getTemplateLine()
);
}

View File

@@ -14,19 +14,13 @@ namespace Twig\Node\Expression\Ternary;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\OperatorEscapeInterface;
use Twig\Node\Expression\ReturnPrimitiveTypeInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\TwigTest;
final class ConditionalTernary extends AbstractExpression implements OperatorEscapeInterface
{
public function __construct(AbstractExpression $test, AbstractExpression $left, AbstractExpression $right, int $lineno)
{
if (!$test instanceof ReturnPrimitiveTypeInterface) {
$test = new TrueTest($test, new TwigTest('true'), null, $test->getTemplateLine());
}
parent::__construct(['test' => $test, 'left' => $left, 'right' => $right], [], $lineno);
parent::__construct(['test' => TrueTest::wrap($test), 'left' => $left, 'right' => $right], [], $lineno);
}
public function compile(Compiler $compiler): void

View File

@@ -59,4 +59,10 @@ class DefinedTest extends TestExpression
{
$compiler->subcompile($this->getNode('node'));
}
public function getStringCoercedChildNames(): array
{
// the `defined` test does not coerce its node to string (it only inspects existence)
return [];
}
}

View File

@@ -33,4 +33,10 @@ class DivisiblebyTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}

View File

@@ -32,4 +32,10 @@ class EvenTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}

View File

@@ -31,4 +31,10 @@ class NullTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// `=== null` is strict, no coercion
return [];
}
}

View File

@@ -32,4 +32,10 @@ class OddTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// PHP `%` rejects Stringable with a TypeError, no coercion
return [];
}
}

View File

@@ -31,4 +31,10 @@ class SameasTest extends TestExpression
->raw(')')
;
}
public function getStringCoercedChildNames(): array
{
// `===` is strict, no coercion
return [];
}
}

View File

@@ -12,7 +12,10 @@
namespace Twig\Node\Expression\Test;
use Twig\Compiler;
use Twig\Node\Expression\ReturnPrimitiveTypeInterface;
use Twig\Node\Expression\TestExpression;
use Twig\Node\Node;
use Twig\TwigTest;
/**
* Checks that an expression is true.
@@ -23,6 +26,15 @@ use Twig\Node\Expression\TestExpression;
*/
class TrueTest extends TestExpression
{
public static function wrap(Node $node): Node
{
if ($node instanceof ReturnPrimitiveTypeInterface) {
return $node;
}
return new self($node, new TwigTest('true', null, ['always_allowed_in_sandbox' => true]), null, $node->getTemplateLine());
}
public function compile(Compiler $compiler): void
{
$compiler
@@ -31,4 +43,10 @@ class TrueTest extends TestExpression
->raw(') && $tmp instanceof Markup ? (string) $tmp : $tmp)')
;
}
public function getStringCoercedChildNames(): array
{
// the `(string)` cast only fires for Markup instances, whose __toString is always allowed
return [];
}
}

View File

@@ -13,11 +13,12 @@ namespace Twig\Node\Expression;
use Twig\Attribute\FirstClassTwigCallableReady;
use Twig\Compiler;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\NameDeprecation;
use Twig\Node\Node;
use Twig\TwigTest;
class TestExpression extends CallExpression implements ReturnBoolInterface
class TestExpression extends CallExpression implements ReturnBoolInterface, CoercesChildrenToStringInterface
{
#[FirstClassTwigCallableReady]
/**
@@ -70,4 +71,21 @@ class TestExpression extends CallExpression implements ReturnBoolInterface
$this->compileCallable($compiler);
}
public function getStringCoercedChildNames(): array
{
$names = [];
// the `empty` test triggers an implicit string coercion through `CoreExtension::testEmpty()`
if ('empty' === $this->getAttribute('name')) {
$names[] = 'node';
}
// a test may coerce its arguments to string (the host PHP code is opaque to Twig)
if ($this->hasNode('arguments')) {
$names[] = 'arguments';
}
return $names;
}
}

View File

@@ -13,9 +13,17 @@
namespace Twig\Node\Expression\Unary;
use Twig\Compiler;
use Twig\Node\Expression\ReturnBoolInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\Node\Node;
class NotUnary extends AbstractUnary
class NotUnary extends AbstractUnary implements ReturnBoolInterface
{
public function __construct(Node $node, int $lineno)
{
parent::__construct(TrueTest::wrap($node), $lineno);
}
public function operator(Compiler $compiler): Compiler
{
return $compiler->raw('!');

View File

@@ -115,6 +115,9 @@ class ForNode extends Node
$compiler->raw(");\n");
// keep the values set in the inner context for variables defined in the outer context
$compiler->write("\$context = array_intersect_key(\$context, \$_parent) + \$_parent;\n");
$compiler
->write("\$context = array_intersect_key(\$context, \$_parent);\n")
->write("\$context += \$_parent;\n")
;
}
}

View File

@@ -14,9 +14,7 @@ namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\ReturnPrimitiveTypeInterface;
use Twig\Node\Expression\Test\TrueTest;
use Twig\TwigTest;
/**
* Represents an if node.
@@ -29,10 +27,7 @@ class IfNode extends Node
public function __construct(Node $tests, ?Node $else, int $lineno)
{
for ($i = 0, $count = \count($tests); $i < $count; $i += 2) {
$test = $tests->getNode((string) $i);
if (!$test instanceof ReturnPrimitiveTypeInterface) {
$tests->setNode($i, new TrueTest($test, new TwigTest('true'), null, $test->getTemplateLine()));
}
$tests->setNode($i, TrueTest::wrap($tests->getNode((string) $i)));
}
$nodes = ['tests' => $tests];
if (null !== $else) {

View File

@@ -23,7 +23,7 @@ use Twig\Node\Expression\Variable\ContextVariable;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
class ImportNode extends Node
class ImportNode extends Node implements CoercesChildrenToStringInterface
{
public function __construct(AbstractExpression $expr, AbstractExpression|AssignTemplateVariable $var, int $lineno)
{
@@ -58,4 +58,10 @@ class ImportNode extends Node
$compiler->raw(";\n");
}
public function getStringCoercedChildNames(): array
{
// the loader resolves the template-name expression by coercing it to a string
return ['expr'];
}
}

View File

@@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
class IncludeNode extends Node implements NodeOutputInterface
class IncludeNode extends Node implements NodeOutputInterface, CoercesChildrenToStringInterface
{
public function __construct(AbstractExpression $expr, ?AbstractExpression $variables, bool $only, bool $ignoreMissing, int $lineno)
{
@@ -60,9 +60,10 @@ class IncludeNode extends Node implements NodeOutputInterface
->write("}\n")
->write(\sprintf("if ($%s) {\n", $template))
->indent()
->write(\sprintf('yield from $%s->unwrap()->yield(', $template))
;
$compiler->write(\sprintf('yield from $%s->unwrap()->yield(', $template));
$this->addTemplateArguments($compiler);
$compiler
->raw(");\n")
@@ -111,4 +112,10 @@ class IncludeNode extends Node implements NodeOutputInterface
$compiler->raw(')');
}
}
public function getStringCoercedChildNames(): array
{
// the loader resolves the template-name expression by coercing it to a string
return ['expr'];
}
}

View File

@@ -28,7 +28,7 @@ use Twig\Source;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
final class ModuleNode extends Node
final class ModuleNode extends Node implements CoercesChildrenToStringInterface
{
/**
* @param BodyNode $body
@@ -90,6 +90,12 @@ final class ModuleNode extends Node
}
}
public function getStringCoercedChildNames(): array
{
// the parent expression is resolved through the loader, which coerces it to a string
return $this->hasNode('parent') ? ['parent'] : [];
}
/**
* @return void
*/
@@ -178,6 +184,7 @@ final class ModuleNode extends Node
->write("use Twig\Sandbox\SecurityNotAllowedTagError;\n")
->write("use Twig\Sandbox\SecurityNotAllowedFilterError;\n")
->write("use Twig\Sandbox\SecurityNotAllowedFunctionError;\n")
->write("use Twig\Sandbox\SecurityNotAllowedTestError;\n")
->write("use Twig\Source;\n")
->write("use Twig\Template;\n")
->write("use Twig\TemplateWrapper;\n")
@@ -248,11 +255,11 @@ final class ModuleNode extends Node
->string($key)
->raw("])) {\n")
->indent()
->write("throw new RuntimeError('Block ")
->write("throw new RuntimeError(sprintf('Block \"%s\" is not defined in trait \"%s\".', ")
->string($key)
->raw(' is not defined in trait ')
->raw(', ')
->subcompile($trait->getNode('template'))
->raw(".', ")
->raw('), ')
->repr($node->getTemplateLine())
->raw(", \$this->source);\n")
->outdent()

View File

@@ -15,6 +15,8 @@ namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\ReturnStringInterface;
/**
* Represents a node that outputs an expression.
@@ -22,7 +24,7 @@ use Twig\Node\Expression\AbstractExpression;
* @author Fabien Potencier <fabien@symfony.com>
*/
#[YieldReady]
class PrintNode extends Node implements NodeOutputInterface
class PrintNode extends Node implements NodeOutputInterface, CoercesChildrenToStringInterface
{
public function __construct(AbstractExpression $expr, int $lineno)
{
@@ -34,11 +36,34 @@ class PrintNode extends Node implements NodeOutputInterface
/** @var AbstractExpression */
$expr = $this->getNode('expr');
$compiler->addDebugInfo($this);
if ($expr->isGenerator()) {
$compiler->write('yield from ');
} else {
$compiler->write('yield ');
if (!$this->isString($expr)) {
$compiler->raw('(string) ');
}
}
$compiler
->addDebugInfo($this)
->write($expr->isGenerator() ? 'yield from ' : 'yield ')
->subcompile($expr)
->raw(";\n")
;
}
public function getStringCoercedChildNames(): array
{
return ['expr'];
}
private function isString(AbstractExpression $expr): bool
{
if ($expr instanceof ReturnStringInterface) {
return true;
}
return $expr instanceof ConstantExpression && !$expr->isDefinedTestEnabled() && \is_string($expr->getAttribute('value'));
}
}

View File

@@ -38,4 +38,20 @@ class TextNode extends Node implements NodeOutputInterface
->raw(";\n")
;
}
public function isBlank(): bool
{
if (ctype_space($this->getAttribute('data'))) {
return true;
}
if (str_contains((string) $this, \chr(0xEF).\chr(0xBB).\chr(0xBF))) {
$t = substr($this->getAttribute('data'), 3);
if ('' === $t || ctype_space($t)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,223 @@
<?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\NodeVisitor;
use Twig\Environment;
use Twig\Error\SyntaxError;
use Twig\Node\BlockNode;
use Twig\Node\BlockReferenceNode;
use Twig\Node\ConfigNode;
use Twig\Node\MacroNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\NodeCaptureInterface;
use Twig\Node\NodeOutputInterface;
use Twig\Node\Nodes;
use Twig\Node\TextNode;
/**
* @author Fabien Potencier <fabien@symfony.com>
*
* @internal
*/
final class CorrectnessNodeVisitor implements NodeVisitorInterface
{
private ?\WeakMap $rootNodes = null;
/**
* Stack of the output-wrapping tags ("if", "for", "set", ...) currently open;
* the top one is the nearest tag a "block" definition would be nested under.
*
* @var list<Node>
*/
private array $tagStack = [];
private bool $hasParent = false;
private int $blockDepth = 0;
private int $macroDepth = 0;
private int $capturingNodeDepth = 0;
private bool $hasExtends = false;
public function enterNode(Node $node, Environment $env): Node
{
if ($node instanceof ModuleNode) {
$this->enterModule($node);
return $node;
}
$this->enterScope($node);
if ($node instanceof ConfigNode) {
$this->checkConfigTag($node);
}
if ($node instanceof BlockReferenceNode) {
$this->checkBlockDefinitionNesting($node);
}
return $node;
}
public function leaveNode(Node $node, Environment $env): Node
{
if ($node instanceof ModuleNode) {
$this->resetState();
return $node;
}
$this->leaveScope($node);
return $node;
}
public function getPriority(): int
{
return -255;
}
private function enterModule(ModuleNode $node): void
{
$this->resetState();
$this->rootNodes = new \WeakMap();
$this->hasParent = $node->hasNode('parent');
foreach ($this->getRootNodes($node) as $n) {
if ($this->hasParent && !$this->isEmptyOutputNode($n)) {
throw new SyntaxError('A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?', $n->getTemplateLine(), $n->getSourceContext());
}
$this->rootNodes[$n] = true;
}
}
private function resetState(): void
{
$this->rootNodes = null;
$this->tagStack = [];
$this->hasParent = false;
$this->blockDepth = 0;
$this->macroDepth = 0;
$this->capturingNodeDepth = 0;
$this->hasExtends = false;
}
/**
* @return iterable<Node>
*/
private function getRootNodes(ModuleNode $node): iterable
{
$body = $node->getNode('body')->getNode('0');
// Parser::subparse() does not wrap the parsed nodes when there is only one,
// so $body can be a single "real" node instead of a Nodes container; in that
// case it is the only root node and must not be iterated over its children.
return $body instanceof Nodes || Node::class === $body::class ? $body : [$body];
}
private function enterScope(Node $node): void
{
if ($node instanceof NodeCaptureInterface) {
++$this->capturingNodeDepth;
}
if ($node instanceof BlockNode) {
++$this->blockDepth;
} elseif ($node instanceof MacroNode) {
++$this->macroDepth;
} elseif ($node->getNodeTag() && !$node instanceof BlockReferenceNode) {
$this->tagStack[] = $node;
}
}
private function leaveScope(Node $node): void
{
if ($node instanceof NodeCaptureInterface) {
--$this->capturingNodeDepth;
}
if ($node instanceof BlockNode) {
--$this->blockDepth;
} elseif ($node instanceof MacroNode) {
--$this->macroDepth;
} elseif ($node->getNodeTag() && !$node instanceof BlockReferenceNode) {
array_pop($this->tagStack);
}
}
private function checkConfigTag(ConfigNode $node): void
{
if ('extends' === $node->getNodeTag()) {
$this->checkExtendsTag($node);
}
if (!isset($this->rootNodes[$node])) {
trigger_deprecation('twig/twig', '3.27', 'Using the "%s" tag outside the root of a template is deprecated in %s at line %d.', $node->getNodeTag(), $node->getSourceContext()->getName(), $node->getTemplateLine());
}
}
private function checkExtendsTag(ConfigNode $node): void
{
// "extends" inside a "block" or a "macro" has always been a hard error; keep it
if ($this->blockDepth) {
throw new SyntaxError('Cannot use "extend" in a block.', $node->getTemplateLine(), $node->getSourceContext());
}
if ($this->macroDepth) {
throw new SyntaxError('Cannot use "extend" in a macro.', $node->getTemplateLine(), $node->getSourceContext());
}
if ($this->hasExtends) {
throw new SyntaxError('Multiple extends tags are forbidden.', $node->getTemplateLine(), $node->getSourceContext());
}
$this->hasExtends = true;
}
private function checkBlockDefinitionNesting(BlockReferenceNode $node): void
{
// A "block" definition nested under an output-wrapping tag is registered globally
// regardless of that tag, so the nesting is misleading. This only matters at the root
// of a child template's body: once inside a block, a macro, an output capture, or in
// a standalone template, the block is rendered in place and behaves like any other.
if (!$this->hasParent || $this->blockDepth || $this->macroDepth || $this->capturingNodeDepth || !$this->tagStack) {
return;
}
$tag = $this->tagStack[array_key_last($this->tagStack)];
throw new SyntaxError(\sprintf('A "block" tag cannot be under a "%s" tag (line %d).', $tag->getNodeTag(), $tag->getTemplateLine()), $node->getTemplateLine(), $node->getSourceContext());
}
/**
* Returns true if the node never outputs anything or if the output is empty.
*/
private function isEmptyOutputNode(Node $node): bool
{
if ($node instanceof NodeCaptureInterface) {
// a "block" tag in such a node will serve as a block definition AND be displayed in place as well
return true;
}
// Can the text be considered "empty" (only whitespace)?
if ($node instanceof TextNode) {
return $node->isBlank();
}
if (!$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface) {
return false;
}
foreach ($node as $n) {
if (!$this->isEmptyOutputNode($n)) {
return false;
}
}
return true;
}
}

View File

@@ -54,6 +54,11 @@ final class SafeAnalysisNodeVisitor implements NodeVisitorInterface
if (\in_array('html_attr', $bucket['value'], true)) {
$bucket['value'][] = 'html';
$bucket['value'][] = 'html_attr_relaxed';
}
if (\in_array('html_attr_relaxed', $bucket['value'], true)) {
$bucket['value'][] = 'html';
}
return $bucket['value'];

View File

@@ -15,19 +15,22 @@ use Twig\Environment;
use Twig\Node\CheckSecurityCallNode;
use Twig\Node\CheckSecurityNode;
use Twig\Node\CheckToStringNode;
use Twig\Node\CoercesChildrenToStringInterface;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Binary\ConcatBinary;
use Twig\Node\Expression\Binary\RangeBinary;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\Expression\GetAttrExpression;
use Twig\Node\Expression\OperatorEscapeInterface;
use Twig\Node\Expression\TestExpression;
use Twig\Node\Expression\Unary\SpreadUnary;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Node\PrintNode;
use Twig\Node\SetNode;
use Twig\TokenParser\TokenParserInterface;
use Twig\TwigCallableInterface;
use Twig\Util\CallableParameters;
/**
* @author Fabien Potencier <fabien@symfony.com>
@@ -43,7 +46,8 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
private $filters;
/** @var array<string, int> */
private $functions;
private $needsToStringWrap = false;
/** @var array<string, int> */
private $tests;
public function enterNode(Node $node, Environment $env): Node
{
@@ -52,51 +56,64 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
$this->tags = [];
$this->filters = [];
$this->functions = [];
return $node;
$this->tests = [];
} elseif ($this->inAModule) {
// look for tags
if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()])) {
if ($node->getNodeTag() && !isset($this->tags[$node->getNodeTag()]) && !$this->isTagAlwaysAllowedInSandbox($env, $node->getNodeTag())) {
$this->tags[$node->getNodeTag()] = $node->getTemplateLine();
}
// look for filters
if ($node instanceof FilterExpression && !isset($this->filters[$node->getAttribute('name')])) {
$this->filters[$node->getAttribute('name')] = $node->getTemplateLine();
if ($node instanceof FilterExpression && !isset($this->filters[$name = $node->getAttribute('name')]) && !$this->isFilterAlwaysAllowedInSandbox($env, $node)) {
$this->filters[$name] = $node->getTemplateLine();
}
// look for functions
if ($node instanceof FunctionExpression && !isset($this->functions[$node->getAttribute('name')])) {
$this->functions[$node->getAttribute('name')] = $node->getTemplateLine();
if ($node instanceof FunctionExpression && !isset($this->functions[$name = $node->getAttribute('name')]) && !$this->isFunctionAlwaysAllowedInSandbox($env, $node)) {
$this->functions[$name] = $node->getTemplateLine();
}
// look for tests
if ($node instanceof TestExpression && !isset($this->tests[$name = $node->getAttribute('name')]) && !$this->isTestAlwaysAllowedInSandbox($env, $node)) {
$this->tests[$name] = $node->getTemplateLine();
}
// look for functions whose parser callable replaced the FunctionExpression
// with a specialized node (e.g. `parent`, `block`, `attribute`); the
// original function name was stashed by FunctionExpressionParser.
if ($node->hasAttribute('sandboxed_function_name')) {
$name = $node->getAttribute('sandboxed_function_name');
if (!isset($this->functions[$name]) && !$this->isSandboxedFunctionAlwaysAllowedInSandbox($env, $node, $name)) {
$this->functions[$name] = $node->getTemplateLine();
}
}
// the .. operator is equivalent to the range() function
if ($node instanceof RangeBinary && !isset($this->functions['range'])) {
if ($node instanceof RangeBinary && !isset($this->functions['range']) && !$this->isFunctionNameAlwaysAllowedInSandbox($env, 'range')) {
$this->functions['range'] = $node->getTemplateLine();
}
}
if ($node instanceof PrintNode) {
$this->needsToStringWrap = true;
$this->wrapNode($node, 'expr');
}
// wrap children that the node itself will string-coerce at runtime;
// applies to ModuleNode (`parent` slot for {% extends %}) too
if ($this->inAModule && $node instanceof CoercesChildrenToStringInterface) {
$params = CallableParameters::fromNode($node, $env);
foreach ($node->getStringCoercedChildNames() as $childName) {
// For Filter/Function/Test calls, consult the PHP callable
// signature: skip wrapping arguments whose param type cannot
// implicitly string-coerce (e.g. `int`, a `final` value object).
if (null !== $params && 'arguments' === $childName) {
$this->wrapArguments($node, $params);
if ($node instanceof SetNode && !$node->getAttribute('capture')) {
$this->needsToStringWrap = true;
}
// wrap outer nodes that can implicitly call __toString()
if ($this->needsToStringWrap) {
if ($node instanceof ConcatBinary) {
$this->wrapNode($node, 'left');
$this->wrapNode($node, 'right');
continue;
}
if ($node instanceof FilterExpression) {
$this->wrapNode($node, 'node');
$this->wrapArrayNode($node, 'arguments');
}
if ($node instanceof FunctionExpression) {
$this->wrapArrayNode($node, 'arguments');
if (null !== $params && 'node' === $childName && $node instanceof FilterExpression) {
// The filter's input value maps to the first PHP parameter.
if (isset($params[0]) && CallableParameters::isStringCoercionSafe($params[0]->getType(), $params[0]->getDeclaringClass())) {
continue;
}
}
$this->wrapNode($node, $childName);
}
}
@@ -109,36 +126,166 @@ final class SandboxNodeVisitor implements NodeVisitorInterface
$this->inAModule = false;
$node->setNode('constructor_end', new Nodes([new CheckSecurityCallNode(), $node->getNode('constructor_end')]));
$node->setNode('class_end', new Nodes([new CheckSecurityNode($this->filters, $this->tags, $this->functions), $node->getNode('class_end')]));
} elseif ($this->inAModule) {
if ($node instanceof PrintNode || $node instanceof SetNode) {
$this->needsToStringWrap = false;
}
$node->setNode('class_end', new Nodes([new CheckSecurityNode($this->filters, $this->tags, $this->functions, $this->tests), $node->getNode('class_end')]));
}
return $node;
}
private function wrapNode(Node $node, string $name): void
/**
* Wraps each entry in the `arguments` slot only when the corresponding
* PHP parameter type can implicitly string-coerce.
*
* @param list<\ReflectionParameter> $params parameters relative to the
* first template argument (for
* filters and tests: starting
* after the `node`/input
* parameter)
*/
private function wrapArguments(Node $node, array $params): void
{
$expr = $node->getNode($name);
if (($expr instanceof ContextVariable || $expr instanceof GetAttrExpression) && !$expr->isGenerator()) {
$node->setNode($name, new CheckToStringNode($expr));
} elseif ($expr instanceof SpreadUnary) {
$this->wrapNode($expr, 'node');
} elseif ($expr instanceof ArrayExpression) {
foreach ($expr as $name => $_) {
$this->wrapNode($expr, $name);
$arguments = $node->getNode('arguments');
if (!$arguments instanceof Nodes && !$arguments instanceof ArrayExpression) {
$this->wrapNode($node, 'arguments');
return;
}
// Filters and tests pass their input value (`node`) as the first PHP
// param, so their template arguments start at offset 1.
$positional = \array_slice($params, $node->hasNode('node') ? 1 : 0);
$variadic = null;
$byName = [];
foreach ($positional as $p) {
if ($p->isVariadic()) {
$variadic = $p;
break;
}
$byName[$this->normalizeName($p->getName())] ??= $p;
}
$positionalIdx = 0;
foreach ($arguments as $key => $_) {
if (\is_int($key)) {
$param = $positional[$positionalIdx] ?? $variadic;
if (null !== $param && !$param->isVariadic()) {
++$positionalIdx;
}
} else {
$param = $byName[$this->normalizeName($key)] ?? $variadic;
}
if (null !== $param && CallableParameters::isStringCoercionSafe($param->getType(), $param->getDeclaringClass())) {
continue;
}
$this->wrapNode($arguments, (string) $key);
}
}
private function wrapArrayNode(Node $node, string $name): void
private function normalizeName(string $name): string
{
$args = $node->getNode($name);
foreach ($args as $name => $_) {
$this->wrapNode($args, $name);
return strtolower(str_replace('_', '', $name));
}
private function wrapNode(Node $node, string $name): void
{
$expr = $node->getNode($name);
// `_self` is internal: it compiles to `$this->getTemplateName()` and is always a string
if ($expr instanceof ContextVariable && '_self' === $expr->getAttribute('name')) {
return;
}
if (($expr instanceof ContextVariable || $expr instanceof GetAttrExpression) && !$expr->isGenerator()) {
$node->setNode($name, new CheckToStringNode($expr));
} elseif ($expr instanceof SpreadUnary) {
$expr->setNode('node', new CheckToStringNode($expr->getNode('node'), true));
} elseif ($expr instanceof ArrayExpression || $expr instanceof Nodes) {
foreach ($expr as $name => $_) {
$this->wrapNode($expr, $name);
}
} elseif ($expr instanceof OperatorEscapeInterface) {
foreach ($expr->getOperandNamesToEscape() as $operandName) {
$this->wrapNode($expr, $operandName);
}
} elseif ($expr instanceof FilterExpression || $expr instanceof FunctionExpression) {
$node->setNode($name, new CheckToStringNode($expr));
}
}
private function isTagAlwaysAllowedInSandbox(Environment $env, string $name): bool
{
if (null === $parser = $env->getTokenParser($name)) {
return false;
}
return self::isAlwaysAllowedInSandbox($parser);
}
private function isFilterAlwaysAllowedInSandbox(Environment $env, FilterExpression $node): bool
{
if ($node->hasAttribute('twig_callable')) {
$filter = $node->getAttribute('twig_callable');
} elseif (null === $filter = $env->getFilter($node->getAttribute('name'))) {
return false;
}
return self::isAlwaysAllowedInSandbox($filter);
}
private function isFunctionAlwaysAllowedInSandbox(Environment $env, FunctionExpression $node): bool
{
if ($node->hasAttribute('twig_callable')) {
$function = $node->getAttribute('twig_callable');
} elseif (null === $function = $env->getFunction($node->getAttribute('name'))) {
return false;
}
return self::isAlwaysAllowedInSandbox($function);
}
private function isTestAlwaysAllowedInSandbox(Environment $env, TestExpression $node): bool
{
if ($node->hasAttribute('twig_callable')) {
$test = $node->getAttribute('twig_callable');
} elseif (null === $test = $env->getTest($node->getAttribute('name'))) {
return false;
}
return self::isAlwaysAllowedInSandbox($test);
}
private function isSandboxedFunctionAlwaysAllowedInSandbox(Environment $env, Node $node, string $name): bool
{
if ($node->hasAttribute('sandboxed_function')) {
$function = $node->getAttribute('sandboxed_function');
} elseif (null === $function = $env->getFunction($name)) {
return false;
}
return self::isAlwaysAllowedInSandbox($function);
}
private function isFunctionNameAlwaysAllowedInSandbox(Environment $env, string $name): bool
{
if (null === $function = $env->getFunction($name)) {
return false;
}
return self::isAlwaysAllowedInSandbox($function);
}
/**
* @param TwigCallableInterface|TokenParserInterface $subject
*/
private static function isAlwaysAllowedInSandbox($subject): bool
{
if (method_exists($subject, 'isAlwaysAllowedInSandbox')) {
return $subject->isAlwaysAllowedInSandbox();
}
$interface = $subject instanceof TokenParserInterface ? TokenParserInterface::class : TwigCallableInterface::class;
trigger_deprecation('twig/twig', '3.28', 'Not implementing the "isAlwaysAllowedInSandbox()" method in "%s" is deprecated. This method will be part of the "%s" interface in 4.0.', $subject::class, $interface);
return false;
}
public function getPriority(): int

View File

@@ -29,8 +29,6 @@ use Twig\Node\Expression\Variable\TemplateVariable;
use Twig\Node\MacroNode;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\NodeCaptureInterface;
use Twig\Node\NodeOutputInterface;
use Twig\Node\Nodes;
use Twig\Node\PrintNode;
use Twig\Node\TextNode;
@@ -54,6 +52,7 @@ class Parser
private $importedSymbols;
private $traits;
private $embeddedTemplates = [];
private int $lastEmbedIndex = 0;
private $varNameSalt = 0;
private $ignoreUnknownTwigCallables = false;
private ExpressionParsers $parsers;
@@ -81,8 +80,13 @@ class Parser
*/
public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode
{
// reset on root parse() calls only, so the counter spans nested/reentrant parses
if (!$this->stack) {
$this->lastEmbedIndex = 0;
}
$vars = get_object_vars($this);
unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['expressionParser'], $vars['reservedMacroNames'], $vars['varNameSalt']);
unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['expressionParser'], $vars['reservedMacroNames'], $vars['lastEmbedIndex'], $vars['varNameSalt']);
$this->stack[] = $vars;
// node visitors
@@ -102,10 +106,6 @@ class Parser
try {
$body = $this->subparse($test, $dropNeedle);
if (null !== $this->parent && null === $body = $this->filterBodyNodes($body)) {
$body = new EmptyNode();
}
} catch (SyntaxError $e) {
if (!$e->getSourceContext()) {
$e->setSourceContext($this->stream->getSourceContext());
@@ -120,6 +120,10 @@ class Parser
$this->expressionRefs = null;
}
if ($this->parent) {
$body = $this->cleanupBodyForChildTemplates($body);
}
$node = new ModuleNode(
new BodyNode([$body]),
$this->parent,
@@ -319,7 +323,7 @@ class Parser
*/
public function embedTemplate(ModuleNode $template)
{
$template->setIndex(mt_rand());
$template->setIndex(++$this->lastEmbedIndex);
$this->embeddedTemplates[] = $template;
}
@@ -410,13 +414,21 @@ class Parser
return $this->parent || 0 < \count($this->traits);
}
public function setParent(?Node $parent): void
public function setParent(?Node $parent, bool $throwOnMultiple = true): void
{
if (null === $parent) {
trigger_deprecation('twig/twig', '3.12', 'Passing "null" to "%s()" is deprecated.', __METHOD__);
}
if (null !== $parent && !$parent instanceof AbstractExpression) {
trigger_deprecation('twig/twig', '3.24', 'Passing a "%s" instance to "%s()" is deprecated, pass an "AbstractExpression" instance instead.', $parent::class, __METHOD__);
}
if (null !== $this->parent) {
if (!$throwOnMultiple) {
return;
}
throw new SyntaxError('Multiple extends tags are forbidden.', $parent->getTemplateLine(), $parent->getSourceContext());
}
@@ -447,7 +459,7 @@ class Parser
if (!$function) {
if ($this->shouldIgnoreUnknownTwigCallables()) {
return new TwigFunction($name, fn () => '');
return new TwigFunction($name, static fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" function.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getFunctions()));
@@ -476,7 +488,7 @@ class Parser
}
if (!$filter) {
if ($this->shouldIgnoreUnknownTwigCallables()) {
return new TwigFilter($name, fn () => '');
return new TwigFilter($name, static fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" filter.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getFilters()));
@@ -524,7 +536,7 @@ class Parser
if (!$test) {
if ($this->shouldIgnoreUnknownTwigCallables()) {
return new TwigTest($name, fn () => '');
return new TwigTest($name, static fn () => '');
}
$e = new SyntaxError(\sprintf('Unknown "%s" test.', $name), $line, $this->stream->getSourceContext());
$e->addSuggestions($name, array_keys($this->env->getTests()));
@@ -540,52 +552,23 @@ class Parser
return $test;
}
private function filterBodyNodes(Node $node, bool $nested = false): ?Node
private function cleanupBodyForChildTemplates(Node $body): Node
{
// check that the body does not contain non-empty output nodes
if (
($node instanceof TextNode && !ctype_space($node->getAttribute('data')))
|| (!$node instanceof TextNode && !$node instanceof BlockReferenceNode && $node instanceof NodeOutputInterface)
) {
if (str_contains((string) $node, \chr(0xEF).\chr(0xBB).\chr(0xBF))) {
$t = substr($node->getAttribute('data'), 3);
if ('' === $t || ctype_space($t)) {
// bypass empty nodes starting with a BOM
return null;
}
}
throw new SyntaxError('A template that extends another one cannot include content outside Twig blocks. Did you forget to put the content inside a {% block %} tag?', $node->getTemplateLine(), $this->stream->getSourceContext());
if ($body instanceof BlockReferenceNode || ($body instanceof TextNode && $body->isBlank())) {
return new EmptyNode();
}
// bypass nodes that "capture" the output
if ($node instanceof NodeCaptureInterface) {
// a "block" tag in such a node will serve as a block definition AND be displayed in place as well
return $node;
}
// "block" tags that are not captured (see above) are only used for defining
// the content of the block. In such a case, nesting it does not work as
// expected as the definition is not part of the default template code flow.
if ($nested && $node instanceof BlockReferenceNode) {
throw new SyntaxError('A block definition cannot be nested under non-capturing nodes.', $node->getTemplateLine(), $this->stream->getSourceContext());
}
if ($node instanceof NodeOutputInterface) {
return null;
}
// here, $nested means "being at the root level of a child template"
// we need to discard the wrapping "Node" for the "body" node
// Node::class !== \get_class($node) should be removed in Twig 4.0
$nested = $nested || (Node::class !== $node::class && !$node instanceof Nodes);
foreach ($node as $k => $n) {
if (null !== $n && null === $this->filterBodyNodes($n, $nested)) {
$node->removeNode($k);
foreach ($body as $k => $node) {
if ($node instanceof BlockReferenceNode) {
// as it has a parent, the block reference won't be used
$body->removeNode($k);
} elseif ($node instanceof TextNode && $node->isBlank()) {
// remove nodes considered as "empty"
$body->removeNode($k);
}
}
return $node;
return $body;
}
private function checkPrecedenceDeprecations(ExpressionParserInterface $expressionParser, AbstractExpression $expr)

View File

@@ -31,11 +31,16 @@ abstract class BaseDumper
abstract protected function formatTime(Profile $profile, $percent): string;
protected function formatRoot(Profile $profile): string
{
return $profile->getName();
}
private function dumpProfile(Profile $profile, $prefix = '', $sibling = false): string
{
if ($profile->isRoot()) {
$this->root = $profile->getDuration();
$start = $profile->getName();
$start = $this->formatRoot($profile);
} else {
if ($profile->isTemplate()) {
$start = $this->formatTemplate($profile, $prefix);

View File

@@ -30,18 +30,28 @@ final class HtmlDumper extends BaseDumper
return '<pre>'.parent::dump($profile).'</pre>';
}
protected function formatRoot(Profile $profile): string
{
return self::escape($profile->getName());
}
protected function formatTemplate(Profile $profile, $prefix): string
{
return \sprintf('%s└ <span style="background-color: %s">%s</span>', $prefix, self::$colors['template'], $profile->getTemplate());
return \sprintf('%s└ <span style="background-color: %s">%s</span>', $prefix, self::$colors['template'], self::escape($profile->getTemplate()));
}
protected function formatNonTemplate(Profile $profile, $prefix): string
{
return \sprintf('%s└ %s::%s(<span style="background-color: %s">%s</span>)', $prefix, $profile->getTemplate(), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', $profile->getName());
return \sprintf('%s└ %s::%s(<span style="background-color: %s">%s</span>)', $prefix, self::escape($profile->getTemplate()), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', self::escape($profile->getName()));
}
protected function formatTime(Profile $profile, $percent): string
{
return \sprintf('<span style="color: %s">%.2fms/%.0f%%</span>', $percent > 20 ? self::$colors['big'] : 'auto', $profile->getDuration() * 1000, $percent);
}
private static function escape(string $value): string
{
return htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
}
}

View File

@@ -173,7 +173,7 @@ final class Profile implements \IteratorAggregate, \Serializable
public function unserialize($data): void
{
$this->__unserialize(unserialize($data));
$this->__unserialize(unserialize($data, ['allowed_classes' => [self::class]]));
}
/**

View File

@@ -11,6 +11,9 @@
use Twig\Environment;
use Twig\Extension\CoreExtension;
use Twig\Extension\SandboxExtension;
use Twig\Source;
use Twig\Template;
/**
* @internal
@@ -237,7 +240,7 @@ function twig_sort_filter(Environment $env, $array, $arrow = null)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::sort($env, $array, $arrow);
return CoreExtension::sort($env, twig_resolve_is_sandboxed($env), $array, $arrow);
}
/**
@@ -461,11 +464,11 @@ function twig_array_batch($items, $size, $fill = null, $preserveKeys = true)
*
* @deprecated since Twig 3.9
*/
function twig_array_column($array, $name, $index = null): array
function twig_array_column(Environment $env, $array, $name, $index = null): array
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::column($array, $name, $index);
return CoreExtension::column($env, twig_resolve_is_sandboxed($env), $array, $name, $index);
}
/**
@@ -477,7 +480,7 @@ function twig_array_filter(Environment $env, $array, $arrow)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::filter($env, $array, $arrow);
return CoreExtension::filter($env, twig_resolve_is_sandboxed($env), $array, $arrow);
}
/**
@@ -489,7 +492,7 @@ function twig_array_map(Environment $env, $array, $arrow)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::map($env, $array, $arrow);
return CoreExtension::map($env, twig_resolve_is_sandboxed($env), $array, $arrow);
}
/**
@@ -501,7 +504,7 @@ function twig_array_reduce(Environment $env, $array, $arrow, $initial = null)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::reduce($env, $array, $arrow, $initial);
return CoreExtension::reduce($env, twig_resolve_is_sandboxed($env), $array, $arrow, $initial);
}
/**
@@ -513,7 +516,7 @@ function twig_array_some(Environment $env, $array, $arrow)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::arraySome($env, $array, $arrow);
return CoreExtension::arraySome($env, $array, $arrow, twig_resolve_is_sandboxed($env));
}
/**
@@ -525,7 +528,7 @@ function twig_array_every(Environment $env, $array, $arrow)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
return CoreExtension::arrayEvery($env, $array, $arrow);
return CoreExtension::arrayEvery($env, $array, $arrow, twig_resolve_is_sandboxed($env));
}
/**
@@ -537,5 +540,33 @@ function twig_check_arrow_in_sandbox(Environment $env, $arrow, $thing, $type)
{
trigger_deprecation('twig/twig', '3.9', 'Using the internal "%s" function is deprecated.', __FUNCTION__);
CoreExtension::checkArrow($env, $arrow, $thing, $type);
CoreExtension::checkArrow(twig_resolve_is_sandboxed($env), $arrow, $thing, $type);
}
/**
* Recovers the calling Template's Source by walking the PHP backtrace.
*
* @internal
*/
function twig_resolve_caller_source(): ?Source
{
foreach (debug_backtrace(\DEBUG_BACKTRACE_PROVIDE_OBJECT | \DEBUG_BACKTRACE_IGNORE_ARGS) as $trace) {
if (isset($trace['object']) && $trace['object'] instanceof Template) {
return $trace['object']->getSourceContext();
}
}
return null;
}
/**
* @internal
*/
function twig_resolve_is_sandboxed(Environment $env): bool
{
if (!$env->hasExtension(SandboxExtension::class)) {
return false;
}
return $env->getExtension(SandboxExtension::class)->isSandboxed(twig_resolve_caller_source());
}

View File

@@ -124,7 +124,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
}
$string = (string) $string;
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'url'], true)) {
} elseif (\in_array($strategy, ['html', 'js', 'css', 'html_attr', 'html_attr_relaxed', 'url'], true)) {
// we return the input as is (which can be of any type)
return $string;
}
@@ -191,7 +191,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
}
$string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', function ($matches) {
$string = preg_replace_callback('#[^a-zA-Z0-9,\._]#Su', static function ($matches) {
$char = $matches[0];
/*
@@ -243,7 +243,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
}
$string = preg_replace_callback('#[^a-zA-Z0-9]#Su', function ($matches) {
$string = preg_replace_callback('#[^a-zA-Z0-9]#Su', static function ($matches) {
$char = $matches[0];
return \sprintf('\\%X ', 1 === \strlen($char) ? \ord($char) : mb_ord($char, 'UTF-8'));
@@ -256,6 +256,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
return $string;
case 'html_attr':
case 'html_attr_relaxed':
if ('UTF-8' !== $charset) {
$string = $this->convertEncoding($string, 'UTF-8', $charset);
}
@@ -264,7 +265,12 @@ final class EscaperRuntime implements RuntimeExtensionInterface
throw new RuntimeError('The string to escape is not a valid UTF-8 string.');
}
$string = preg_replace_callback('#[^a-zA-Z0-9,\.\-_]#Su', function ($matches) {
$regex = match ($strategy) {
'html_attr' => '#[^a-zA-Z0-9,\.\-_]#Su',
'html_attr_relaxed' => '#[^a-zA-Z0-9,\.\-_:@\[\]]#Su',
};
$string = preg_replace_callback($regex, static function ($matches) {
/**
* This function is adapted from code coming from Zend Framework.
*
@@ -323,7 +329,7 @@ final class EscaperRuntime implements RuntimeExtensionInterface
return $this->escapers[$strategy]($string, $charset);
}
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr'], array_keys($this->escapers)));
$validStrategies = implode('", "', array_merge(['html', 'js', 'url', 'css', 'html_attr', 'html_attr_relaxed'], array_keys($this->escapers)));
throw new RuntimeError(\sprintf('Invalid escaping strategy "%s" (valid ones: "%s").', $strategy, $validStrategies));
}

View File

@@ -0,0 +1,33 @@
<?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\Sandbox;
/**
* Exception thrown when a not allowed test is used in a template.
*
* @author Fabien Potencier <fabien@symfony.com>
*/
final class SecurityNotAllowedTestError extends SecurityError
{
private string $testName;
public function __construct(string $message, string $testName)
{
parent::__construct($message);
$this->testName = $testName;
}
public function getTestName(): string
{
return $this->testName;
}
}

View File

@@ -26,14 +26,17 @@ final class SecurityPolicy implements SecurityPolicyInterface
private $allowedMethods;
private $allowedProperties;
private $allowedFunctions;
private array $allowedTests;
private bool $strict = false;
public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = [])
public function __construct(array $allowedTags = [], array $allowedFilters = [], array $allowedMethods = [], array $allowedProperties = [], array $allowedFunctions = [], array $allowedTests = [])
{
$this->allowedTags = $allowedTags;
$this->allowedFilters = $allowedFilters;
$this->setAllowedMethods($allowedMethods);
$this->allowedProperties = $allowedProperties;
$this->allowedFunctions = $allowedFunctions;
$this->allowedTests = $allowedTests;
}
public function setAllowedTags(array $tags): void
@@ -64,14 +67,38 @@ final class SecurityPolicy implements SecurityPolicyInterface
$this->allowedFunctions = $functions;
}
public function checkSecurity($tags, $filters, $functions): void
public function setAllowedTests(array $tests): void
{
$this->allowedTests = $tests;
}
/**
* Toggles strict mode.
*
* In strict mode, the tags, functions, and tests that are historically always
* allowed in a sandbox (the ``extends`` and ``use`` tags, the ``parent``,
* ``block``, and ``attribute`` functions, and any test) are no longer implicitly
* allowed and must be added to the relevant allow-list to be usable. Use this
* flag in 3.x to opt-in to the forthcoming 4.0 behavior and silence the related
* deprecations.
*/
public function setStrict(bool $strict): void
{
$this->strict = $strict;
}
public function checkSecurity($tags, $filters, $functions, array $tests = []): void
{
if (\func_num_args() < 4) {
trigger_deprecation('twig/twig', '3.28', 'Not passing the "$tests" argument to "%s::checkSecurity()" is deprecated; it will be required in 4.0.', static::class);
}
foreach ($tags as $tag) {
if (!\in_array($tag, $this->allowedTags, true)) {
if ('extends' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "extends" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
} elseif ('use' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "use" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed.');
if (!$this->strict && 'extends' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "extends" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'use' === $tag) {
trigger_deprecation('twig/twig', '3.12', 'The "use" tag is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} else {
throw new SecurityNotAllowedTagError(\sprintf('Tag "%s" is not allowed.', $tag), $tag);
}
@@ -86,7 +113,25 @@ final class SecurityPolicy implements SecurityPolicyInterface
foreach ($functions as $function) {
if (!\in_array($function, $this->allowedFunctions, true)) {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
if (!$this->strict && 'parent' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "parent" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'block' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "block" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} elseif (!$this->strict && 'attribute' === $function) {
trigger_deprecation('twig/twig', '3.27', 'The "attribute" function is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).');
} else {
throw new SecurityNotAllowedFunctionError(\sprintf('Function "%s" is not allowed.', $function), $function);
}
}
}
foreach ($tests as $test) {
if (!\in_array($test, $this->allowedTests, true)) {
if (!$this->strict) {
trigger_deprecation('twig/twig', '3.28', 'The "%s" test is always allowed in sandboxes, but won\'t be in 4.0, please enable it explicitly in your sandbox policy if needed (or enable strict mode on the security policy to opt-in to the 4.0 behavior now).', $test);
} else {
throw new SecurityNotAllowedTestError(\sprintf('Test "%s" is not allowed.', $test), $test);
}
}
}
}

View File

@@ -22,10 +22,11 @@ interface SecurityPolicyInterface
* @param string[] $tags
* @param string[] $filters
* @param string[] $functions
* @param string[] $tests
*
* @throws SecurityError
*/
public function checkSecurity($tags, $filters, $functions): void;
public function checkSecurity($tags, $filters, $functions/* , array $tests */): void;
/**
* @param object $obj

View File

@@ -17,6 +17,8 @@ use Twig\Source;
* Interface for a class that can optionally enable the sandbox mode based on a template's Twig\Source.
*
* @author Yaakov Saxon
*
* @deprecated since Twig 3.27.0 with no replacement
*/
interface SourcePolicyInterface
{

View File

@@ -44,4 +44,23 @@ final class Source
{
return $this->path;
}
/**
* Returns the 1-based column for a 0-based byte offset in the source code.
*
* A negative offset means the position is unknown and yields null.
*
* @return positive-int|null
*/
public function getColumn(int $offset): ?int
{
if ($offset < 0) {
return null;
}
$before = str_replace(["\r\n", "\r"], "\n", substr($this->code, 0, $offset));
$lineStart = strrpos($before, "\n");
return false === $lineStart ? \strlen($before) + 1 : \strlen($before) - $lineStart;
}
}

View File

@@ -80,6 +80,14 @@ abstract class Template
return $this->parent;
}
// The compiled doGetParent() may evaluate user expressions (filters,
// functions, method calls) when the parent name is dynamic. Make sure
// the sandbox security check runs first so those expressions cannot
// bypass the allow-list when getParent() is reached before the first
// ensureSecurityChecked() call on this template (e.g. via
// getTemplateForMacro() or yieldBlock() into a pre-warmed instance).
$this->ensureSecurityChecked();
if (!$parent = $this->doGetParent($context)) {
return false;
}
@@ -158,7 +166,7 @@ abstract class Template
if ($this->env->isDebug()) {
ob_start();
} else {
ob_start(function () { return ''; });
ob_start(static function () { return ''; });
}
$this->displayParentBlock($name, $context, $blocks);
@@ -193,7 +201,7 @@ abstract class Template
if ($this->env->isDebug()) {
ob_start();
} else {
ob_start(function () { return ''; });
ob_start(static function () { return ''; });
}
try {
$this->displayBlock($name, $context, $blocks, $useBlocks);
@@ -367,7 +375,7 @@ abstract class Template
if ($this->env->isDebug()) {
ob_start();
} else {
ob_start(function () { return ''; });
ob_start(static function () { return ''; });
}
try {
$this->display($context);
@@ -399,6 +407,7 @@ abstract class Template
$blocks = array_merge($this->blocks, $blocks);
try {
$this->ensureSecurityChecked();
yield from $this->doDisplay($context, $blocks);
} catch (Error $e) {
if (!$e->getSourceContext()) {
@@ -431,6 +440,8 @@ abstract class Template
} elseif (isset($this->blocks[$name])) {
$template = $this->blocks[$name][0];
$block = $this->blocks[$name][1];
// expose this template's own blocks so nested block() calls resolve against them when the block is rendered directly (e.g. block(name, template))
$blocks = array_merge($this->blocks, $blocks);
} else {
$template = null;
$block = null;
@@ -443,6 +454,7 @@ abstract class Template
if (null !== $template) {
try {
$template->ensureSecurityChecked();
yield from $template->$block($context, $blocks);
} catch (Error $e) {
if (!$e->getSourceContext()) {
@@ -510,12 +522,16 @@ abstract class Template
protected function getTemplateForMacro(string $name, array $context, int $line, Source $source): self
{
if (method_exists($this, $name)) {
$this->ensureSecurityChecked();
return $this;
}
$parent = $this;
while ($parent = $parent->getParent($context)) {
if (method_exists($parent, $name)) {
$parent->ensureSecurityChecked();
return $parent;
}
}
@@ -523,6 +539,15 @@ abstract class Template
throw new RuntimeError(\sprintf('Macro "%s" is not defined in template "%s".', substr($name, \strlen('macro_')), $this->getTemplateName()), $line, $source);
}
/**
* Runs the sandbox security check against the current sandbox state.
*
* @internal
*/
public function ensureSecurityChecked(): void
{
}
/**
* Auto-generated method to display the template with the given context.
*

View File

@@ -39,10 +39,14 @@ final class Token
*/
public const SPREAD_TYPE = 13;
/**
* @param non-negative-int|null $offset
*/
public function __construct(
private int $type,
private $value,
private int $lineno,
private ?int $offset = null,
) {
if (self::ARROW_TYPE === $type) {
trigger_deprecation('twig/twig', '3.21', 'The "%s" token type is deprecated, "arrow" is now an operator.', self::ARROW_TYPE);
@@ -124,6 +128,19 @@ final class Token
return $this->lineno;
}
/**
* Returns the 0-based byte offset of the token in the source code.
*
* Returns null for tokens that are not tied to a source position (e.g.
* tokens synthesized by a token parser).
*
* @return non-negative-int|null
*/
public function getOffset(): ?int
{
return $this->offset;
}
/**
* @deprecated since Twig 3.19
*/

Some files were not shown because too many files have changed in this diff Show More