diff --git a/composer.lock b/composer.lock index 76900be837..f5a5e172db 100644 --- a/composer.lock +++ b/composer.lock @@ -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": [ diff --git a/lib/composer/autoload_classmap.php b/lib/composer/autoload_classmap.php index a7180508e0..7f1635d804 100644 --- a/lib/composer/autoload_classmap.php +++ b/lib/composer/autoload_classmap.php @@ -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', diff --git a/lib/composer/autoload_static.php b/lib/composer/autoload_static.php index aea2e9b277..59aaf1e221 100644 --- a/lib/composer/autoload_static.php +++ b/lib/composer/autoload_static.php @@ -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', diff --git a/lib/composer/installed.json b/lib/composer/installed.json index 06f129638e..ca5cceb2a4 100644 --- a/lib/composer/installed.json +++ b/lib/composer/installed.json @@ -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": [ { diff --git a/lib/composer/installed.php b/lib/composer/installed.php index 73adf43ea1..d443dc9923 100644 --- a/lib/composer/installed.php +++ b/lib/composer/installed.php @@ -1,9 +1,9 @@ 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(), diff --git a/lib/twig/twig/CHANGELOG b/lib/twig/twig/CHANGELOG index cf47a76043..74877d6325 100644 --- a/lib/twig/twig/CHANGELOG +++ b/lib/twig/twig/CHANGELOG @@ -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) diff --git a/lib/twig/twig/composer.json b/lib/twig/twig/composer.json index 366236637c..788ebe708a 100644 --- a/lib/twig/twig/composer.json +++ b/lib/twig/twig/composer.json @@ -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": [ diff --git a/lib/twig/twig/splitsh.json b/lib/twig/twig/splitsh.json deleted file mode 100644 index f415dd8d4e..0000000000 --- a/lib/twig/twig/splitsh.json +++ /dev/null @@ -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" - } -} diff --git a/lib/twig/twig/src/AbstractTwigCallable.php b/lib/twig/twig/src/AbstractTwigCallable.php index 804f336cbe..4ea9f66958 100644 --- a/lib/twig/twig/src/AbstractTwigCallable.php +++ b/lib/twig/twig/src/AbstractTwigCallable.php @@ -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); } } diff --git a/lib/twig/twig/src/Attribute/AsTwigFilter.php b/lib/twig/twig/src/Attribute/AsTwigFilter.php index 09f8f01de4..5689c9dfb2 100644 --- a/lib/twig/twig/src/Attribute/AsTwigFilter.php +++ b/lib/twig/twig/src/Attribute/AsTwigFilter.php @@ -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, ) { } } diff --git a/lib/twig/twig/src/Attribute/AsTwigFunction.php b/lib/twig/twig/src/Attribute/AsTwigFunction.php index d161f06f46..a0118c3788 100644 --- a/lib/twig/twig/src/Attribute/AsTwigFunction.php +++ b/lib/twig/twig/src/Attribute/AsTwigFunction.php @@ -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, ) { } } diff --git a/lib/twig/twig/src/Attribute/AsTwigTest.php b/lib/twig/twig/src/Attribute/AsTwigTest.php index 1ce2658b49..707424df78 100644 --- a/lib/twig/twig/src/Attribute/AsTwigTest.php +++ b/lib/twig/twig/src/Attribute/AsTwigTest.php @@ -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, ) { } } diff --git a/lib/twig/twig/src/Compiler.php b/lib/twig/twig/src/Compiler.php index 6f62c09197..976030f795 100644 --- a/lib/twig/twig/src/Compiler.php +++ b/lib/twig/twig/src/Compiler.php @@ -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; } diff --git a/lib/twig/twig/src/Environment.php b/lib/twig/twig/src/Environment.php index 2af6476111..2727997157 100644 --- a/lib/twig/twig/src/Environment.php +++ b/lib/twig/twig/src/Environment.php @@ -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 = ''; diff --git a/lib/twig/twig/src/Error/Error.php b/lib/twig/twig/src/Error/Error.php index 97ed2df991..8494d243f0 100644 --- a/lib/twig/twig/src/Error/Error.php +++ b/lib/twig/twig/src/Error/Error.php @@ -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; diff --git a/lib/twig/twig/src/ExpressionParser/AbstractExpressionParser.php b/lib/twig/twig/src/ExpressionParser/AbstractExpressionParser.php index bc05bfa051..b8b963a1fc 100644 --- a/lib/twig/twig/src/ExpressionParser/AbstractExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/AbstractExpressionParser.php @@ -27,4 +27,9 @@ abstract class AbstractExpressionParser implements ExpressionParserInterface { return []; } + + public function getOperatorTokens(): array + { + return [$this->getName(), ...$this->getAliases()]; + } } diff --git a/lib/twig/twig/src/ExpressionParser/ExpressionParserInterface.php b/lib/twig/twig/src/ExpressionParser/ExpressionParserInterface.php index 86576aec49..2013a114e2 100644 --- a/lib/twig/twig/src/ExpressionParser/ExpressionParserInterface.php +++ b/lib/twig/twig/src/ExpressionParser/ExpressionParserInterface.php @@ -11,6 +11,14 @@ namespace Twig\ExpressionParser; +/** + * @method list 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; diff --git a/lib/twig/twig/src/ExpressionParser/ExpressionParsers.php b/lib/twig/twig/src/ExpressionParser/ExpressionParsers.php index fb35a690e2..7efaffc143 100644 --- a/lib/twig/twig/src/ExpressionParser/ExpressionParsers.php +++ b/lib/twig/twig/src/ExpressionParser/ExpressionParsers.php @@ -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 + */ + 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()]; + } } diff --git a/lib/twig/twig/src/ExpressionParser/Infix/AssignmentExpressionParser.php b/lib/twig/twig/src/ExpressionParser/Infix/AssignmentExpressionParser.php index fa8c4f2b02..1f042845c0 100644 --- a/lib/twig/twig/src/ExpressionParser/Infix/AssignmentExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/Infix/AssignmentExpressionParser.php @@ -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 diff --git a/lib/twig/twig/src/ExpressionParser/Infix/DotExpressionParser.php b/lib/twig/twig/src/ExpressionParser/Infix/DotExpressionParser.php index 31418b585b..287fbd18c4 100644 --- a/lib/twig/twig/src/ExpressionParser/Infix/DotExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/Infix/DotExpressionParser.php @@ -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); diff --git a/lib/twig/twig/src/ExpressionParser/Infix/FunctionExpressionParser.php b/lib/twig/twig/src/ExpressionParser/Infix/FunctionExpressionParser.php index e9cd775179..c8851823d6 100644 --- a/lib/twig/twig/src/ExpressionParser/Infix/FunctionExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/Infix/FunctionExpressionParser.php @@ -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()])) { diff --git a/lib/twig/twig/src/ExpressionParser/Prefix/GroupingExpressionParser.php b/lib/twig/twig/src/ExpressionParser/Prefix/GroupingExpressionParser.php index 5c6608da40..95a11762f4 100644 --- a/lib/twig/twig/src/ExpressionParser/Prefix/GroupingExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/Prefix/GroupingExpressionParser.php @@ -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 diff --git a/lib/twig/twig/src/ExpressionParser/Prefix/LiteralExpressionParser.php b/lib/twig/twig/src/ExpressionParser/Prefix/LiteralExpressionParser.php index da3805106d..b467d1aa58 100644 --- a/lib/twig/twig/src/ExpressionParser/Prefix/LiteralExpressionParser.php +++ b/lib/twig/twig/src/ExpressionParser/Prefix/LiteralExpressionParser.php @@ -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'); diff --git a/lib/twig/twig/src/Extension/AttributeExtension.php b/lib/twig/twig/src/Extension/AttributeExtension.php index 74fcbb8570..623fd4f8e1 100644 --- a/lib/twig/twig/src/Extension/AttributeExtension.php +++ b/lib/twig/twig/src/Extension/AttributeExtension.php @@ -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, ]); diff --git a/lib/twig/twig/src/Extension/CoreExtension.php b/lib/twig/twig/src/Extension/CoreExtension.php index 345f406661..9ef620b6a7 100644 --- a/lib/twig/twig/src/Extension/CoreExtension.php +++ b/lib/twig/twig/src/Extension/CoreExtension.php @@ -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); /* diff --git a/lib/twig/twig/src/Extension/EscaperExtension.php b/lib/twig/twig/src/Extension/EscaperExtension.php index c5625fa6a4..6d9d1ce24d 100644 --- a/lib/twig/twig/src/Extension/EscaperExtension.php +++ b/lib/twig/twig/src/Extension/EscaperExtension.php @@ -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)); } /** diff --git a/lib/twig/twig/src/Extension/SandboxExtension.php b/lib/twig/twig/src/Extension/SandboxExtension.php index 5d0f644431..181e58c9c8 100644 --- a/lib/twig/twig/src/Extension/SandboxExtension.php +++ b/lib/twig/twig/src/Extension/SandboxExtension.php @@ -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); } } } diff --git a/lib/twig/twig/src/Extension/StringLoaderExtension.php b/lib/twig/twig/src/Extension/StringLoaderExtension.php index 698d181f17..da5b2dae3f 100644 --- a/lib/twig/twig/src/Extension/StringLoaderExtension.php +++ b/lib/twig/twig/src/Extension/StringLoaderExtension.php @@ -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 diff --git a/lib/twig/twig/src/Lexer.php b/lib/twig/twig/src/Lexer.php index 508a79c025..f208723468 100644 --- a/lib/twig/twig/src/Lexer.php +++ b/lib/twig/twig/src/Lexer.php @@ -13,6 +13,7 @@ namespace Twig; use Twig\Error\SyntaxError; +use Twig\ExpressionParser\ExpressionParsers; /** * @author Fabien Potencier @@ -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 = '(?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); diff --git a/lib/twig/twig/src/Markup.php b/lib/twig/twig/src/Markup.php index a933b69d32..820f091433 100644 --- a/lib/twig/twig/src/Markup.php +++ b/lib/twig/twig/src/Markup.php @@ -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 */ class Markup implements \Countable, \JsonSerializable, \Stringable diff --git a/lib/twig/twig/src/Node/CheckSecurityCallNode.php b/lib/twig/twig/src/Node/CheckSecurityCallNode.php index bb8783bc3d..0667bd0fdb 100644 --- a/lib/twig/twig/src/Node/CheckSecurityCallNode.php +++ b/lib/twig/twig/src/Node/CheckSecurityCallNode.php @@ -27,7 +27,6 @@ class CheckSecurityCallNode extends Node { $compiler ->write("\$this->sandbox = \$this->extensions[SandboxExtension::class];\n") - ->write("\$this->checkSecurity();\n") ; } } diff --git a/lib/twig/twig/src/Node/CheckSecurityNode.php b/lib/twig/twig/src/Node/CheckSecurityNode.php index 6e591aad40..92316aedcf 100644 --- a/lib/twig/twig/src/Node/CheckSecurityNode.php +++ b/lib/twig/twig/src/Node/CheckSecurityNode.php @@ -23,17 +23,24 @@ class CheckSecurityNode extends Node private $usedFilters; private $usedTags; private $usedFunctions; + private $usedTests; /** * @param array $usedFilters * @param array $usedTags * @param array $usedFunctions + * @param array $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() diff --git a/lib/twig/twig/src/Node/CheckToStringNode.php b/lib/twig/twig/src/Node/CheckToStringNode.php index 937240c1d3..11aec9cc0d 100644 --- a/lib/twig/twig/src/Node/CheckToStringNode.php +++ b/lib/twig/twig/src/Node/CheckToStringNode.php @@ -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()) diff --git a/lib/twig/twig/src/Node/CoercesChildrenToStringInterface.php b/lib/twig/twig/src/Node/CoercesChildrenToStringInterface.php new file mode 100644 index 0000000000..e213d1b610 --- /dev/null +++ b/lib/twig/twig/src/Node/CoercesChildrenToStringInterface.php @@ -0,0 +1,40 @@ + + */ +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; +} diff --git a/lib/twig/twig/src/Node/ConfigNode.php b/lib/twig/twig/src/Node/ConfigNode.php new file mode 100644 index 0000000000..758e057054 --- /dev/null +++ b/lib/twig/twig/src/Node/ConfigNode.php @@ -0,0 +1,30 @@ + + */ +#[YieldReady] +final class ConfigNode extends Node +{ + public function __construct(int $lineno) + { + parent::__construct([], [], $lineno); + } +} diff --git a/lib/twig/twig/src/Node/DeprecatedNode.php b/lib/twig/twig/src/Node/DeprecatedNode.php index 0772adfc36..fea8d355b6 100644 --- a/lib/twig/twig/src/Node/DeprecatedNode.php +++ b/lib/twig/twig/src/Node/DeprecatedNode.php @@ -22,7 +22,7 @@ use Twig\Node\Expression\ConstantExpression; * @author Yonel Ceruto */ #[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; + } } diff --git a/lib/twig/twig/src/Node/Expression/ArrayExpression.php b/lib/twig/twig/src/Node/Expression/ArrayExpression.php index f9f719dd94..189e02f7e4 100644 --- a/lib/twig/twig/src/Node/Expression/ArrayExpression.php +++ b/lib/twig/twig/src/Node/Expression/ArrayExpression.php @@ -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) { diff --git a/lib/twig/twig/src/Node/Expression/ArrowFunctionExpression.php b/lib/twig/twig/src/Node/Expression/ArrowFunctionExpression.php index 552b8fe911..9156f7c616 100644 --- a/lib/twig/twig/src/Node/Expression/ArrowFunctionExpression.php +++ b/lib/twig/twig/src/Node/Expression/ArrowFunctionExpression.php @@ -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); } diff --git a/lib/twig/twig/src/Node/Expression/Binary/AndBinary.php b/lib/twig/twig/src/Node/Expression/Binary/AndBinary.php index 454ea70e5b..7ccef174bd 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/AndBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/AndBinary.php @@ -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('&&'); diff --git a/lib/twig/twig/src/Node/Expression/Binary/ConcatBinary.php b/lib/twig/twig/src/Node/Expression/Binary/ConcatBinary.php index 75ee654731..e4ec135dda 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/ConcatBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/ConcatBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/ElvisBinary.php b/lib/twig/twig/src/Node/Expression/Binary/ElvisBinary.php index 25522240b6..1340a4f0cb 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/ElvisBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/ElvisBinary.php @@ -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); } diff --git a/lib/twig/twig/src/Node/Expression/Binary/EqualBinary.php b/lib/twig/twig/src/Node/Expression/Binary/EqualBinary.php index 8c36503558..d438e2b5d8 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/EqualBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/EqualBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/GreaterBinary.php b/lib/twig/twig/src/Node/Expression/Binary/GreaterBinary.php index 71a980b3ee..318c3019df 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/GreaterBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/GreaterBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/GreaterEqualBinary.php b/lib/twig/twig/src/Node/Expression/Binary/GreaterEqualBinary.php index c92e61b379..2dacd9f0fb 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/GreaterEqualBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/GreaterEqualBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/HasEveryBinary.php b/lib/twig/twig/src/Node/Expression/Binary/HasEveryBinary.php index 22b3801181..9b107111a8 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/HasEveryBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/HasEveryBinary.php @@ -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))') ; } diff --git a/lib/twig/twig/src/Node/Expression/Binary/HasSomeBinary.php b/lib/twig/twig/src/Node/Expression/Binary/HasSomeBinary.php index a2a363e99a..86665a348a 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/HasSomeBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/HasSomeBinary.php @@ -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))') ; } diff --git a/lib/twig/twig/src/Node/Expression/Binary/InBinary.php b/lib/twig/twig/src/Node/Expression/Binary/InBinary.php index 31a21e7d69..2f11a54fd5 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/InBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/InBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/LessBinary.php b/lib/twig/twig/src/Node/Expression/Binary/LessBinary.php index 293d98d517..3bb629122d 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/LessBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/LessBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/LessEqualBinary.php b/lib/twig/twig/src/Node/Expression/Binary/LessEqualBinary.php index 239d9fdfea..30217454f3 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/LessEqualBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/LessEqualBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/MatchesBinary.php b/lib/twig/twig/src/Node/Expression/Binary/MatchesBinary.php index 569dfde05f..5dd3dbeb02 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/MatchesBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/MatchesBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/NotEqualBinary.php b/lib/twig/twig/src/Node/Expression/Binary/NotEqualBinary.php index fd24ef9115..c63e32517b 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/NotEqualBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/NotEqualBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/NotInBinary.php b/lib/twig/twig/src/Node/Expression/Binary/NotInBinary.php index 9fd27311fe..f46c88ff8b 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/NotInBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/NotInBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/NullCoalesceBinary.php b/lib/twig/twig/src/Node/Expression/Binary/NullCoalesceBinary.php index a047b6030d..ffba62dc68 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/NullCoalesceBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/NullCoalesceBinary.php @@ -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(), ); } diff --git a/lib/twig/twig/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php b/lib/twig/twig/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php index 2c0853f655..8c5df6a802 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php @@ -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 */ + 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(']'); } diff --git a/lib/twig/twig/src/Node/Expression/Binary/OrBinary.php b/lib/twig/twig/src/Node/Expression/Binary/OrBinary.php index 82dcb7e953..cacffdaa33 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/OrBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/OrBinary.php @@ -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('||'); diff --git a/lib/twig/twig/src/Node/Expression/Binary/RangeBinary.php b/lib/twig/twig/src/Node/Expression/Binary/RangeBinary.php index f318d8e554..2d0d2eb5ba 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/RangeBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/RangeBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/SpaceshipBinary.php b/lib/twig/twig/src/Node/Expression/Binary/SpaceshipBinary.php index c0a28b0a88..2fb5ddf9e9 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/SpaceshipBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/SpaceshipBinary.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/Binary/XorBinary.php b/lib/twig/twig/src/Node/Expression/Binary/XorBinary.php index 6f412d22fe..0e9173aba7 100644 --- a/lib/twig/twig/src/Node/Expression/Binary/XorBinary.php +++ b/lib/twig/twig/src/Node/Expression/Binary/XorBinary.php @@ -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'); diff --git a/lib/twig/twig/src/Node/Expression/BlockReferenceExpression.php b/lib/twig/twig/src/Node/Expression/BlockReferenceExpression.php index cb7d38c575..657a637194 100644 --- a/lib/twig/twig/src/Node/Expression/BlockReferenceExpression.php +++ b/lib/twig/twig/src/Node/Expression/BlockReferenceExpression.php @@ -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 */ -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')) { diff --git a/lib/twig/twig/src/Node/Expression/CallExpression.php b/lib/twig/twig/src/Node/Expression/CallExpression.php index 330d82535c..ad6d3881ae 100644 --- a/lib/twig/twig/src/Node/Expression/CallExpression.php +++ b/lib/twig/twig/src/Node/Expression/CallExpression.php @@ -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()), diff --git a/lib/twig/twig/src/Node/Expression/Filter/DefaultFilter.php b/lib/twig/twig/src/Node/Expression/Filter/DefaultFilter.php index 04ef06cc4e..fc64b88a91 100644 --- a/lib/twig/twig/src/Node/Expression/Filter/DefaultFilter.php +++ b/lib/twig/twig/src/Node/Expression/Filter/DefaultFilter.php @@ -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()); diff --git a/lib/twig/twig/src/Node/Expression/FilterExpression.php b/lib/twig/twig/src/Node/Expression/FilterExpression.php index a66b0266d7..eb947201d1 100644 --- a/lib/twig/twig/src/Node/Expression/FilterExpression.php +++ b/lib/twig/twig/src/Node/Expression/FilterExpression.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/FunctionExpression.php b/lib/twig/twig/src/Node/Expression/FunctionExpression.php index 183145c414..5983962dc5 100644 --- a/lib/twig/twig/src/Node/Expression/FunctionExpression.php +++ b/lib/twig/twig/src/Node/Expression/FunctionExpression.php @@ -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']; + } } diff --git a/lib/twig/twig/src/Node/Expression/GetAttrExpression.php b/lib/twig/twig/src/Node/Expression/GetAttrExpression.php index f207b092cb..3dcd88af52 100644 --- a/lib/twig/twig/src/Node/Expression/GetAttrExpression.php +++ b/lib/twig/twig/src/Node/Expression/GetAttrExpression.php @@ -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'); + } } diff --git a/lib/twig/twig/src/Node/Expression/ListExpression.php b/lib/twig/twig/src/Node/Expression/ListExpression.php index dd7fc1f9cd..9b23857fcc 100644 --- a/lib/twig/twig/src/Node/Expression/ListExpression.php +++ b/lib/twig/twig/src/Node/Expression/ListExpression.php @@ -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 $items + * @param array $items */ public function __construct(array $items, int $lineno) { diff --git a/lib/twig/twig/src/Node/Expression/MacroReferenceExpression.php b/lib/twig/twig/src/Node/Expression/MacroReferenceExpression.php index fd7f1e733a..fc7a7afcc0 100644 --- a/lib/twig/twig/src/Node/Expression/MacroReferenceExpression.php +++ b/lib/twig/twig/src/Node/Expression/MacroReferenceExpression.php @@ -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 */ -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(')') + ; + } } diff --git a/lib/twig/twig/src/Node/Expression/NullCoalesceExpression.php b/lib/twig/twig/src/Node/Expression/NullCoalesceExpression.php index f397f71f03..4548519f50 100644 --- a/lib/twig/twig/src/Node/Expression/NullCoalesceExpression.php +++ b/lib/twig/twig/src/Node/Expression/NullCoalesceExpression.php @@ -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() ); } diff --git a/lib/twig/twig/src/Node/Expression/Ternary/ConditionalTernary.php b/lib/twig/twig/src/Node/Expression/Ternary/ConditionalTernary.php index f7cd78c5c6..3972755e01 100644 --- a/lib/twig/twig/src/Node/Expression/Ternary/ConditionalTernary.php +++ b/lib/twig/twig/src/Node/Expression/Ternary/ConditionalTernary.php @@ -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 diff --git a/lib/twig/twig/src/Node/Expression/Test/DefinedTest.php b/lib/twig/twig/src/Node/Expression/Test/DefinedTest.php index d735029901..73e43720f3 100644 --- a/lib/twig/twig/src/Node/Expression/Test/DefinedTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/DefinedTest.php @@ -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 []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/DivisiblebyTest.php b/lib/twig/twig/src/Node/Expression/Test/DivisiblebyTest.php index 90d58a49a1..ab9dd416b9 100644 --- a/lib/twig/twig/src/Node/Expression/Test/DivisiblebyTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/DivisiblebyTest.php @@ -33,4 +33,10 @@ class DivisiblebyTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/EvenTest.php b/lib/twig/twig/src/Node/Expression/Test/EvenTest.php index a0e3ed62c1..81a06aa692 100644 --- a/lib/twig/twig/src/Node/Expression/Test/EvenTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/EvenTest.php @@ -32,4 +32,10 @@ class EvenTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/NullTest.php b/lib/twig/twig/src/Node/Expression/Test/NullTest.php index be5d388919..904aef927f 100644 --- a/lib/twig/twig/src/Node/Expression/Test/NullTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/NullTest.php @@ -31,4 +31,10 @@ class NullTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // `=== null` is strict, no coercion + return []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/OddTest.php b/lib/twig/twig/src/Node/Expression/Test/OddTest.php index d56c711169..967c353110 100644 --- a/lib/twig/twig/src/Node/Expression/Test/OddTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/OddTest.php @@ -32,4 +32,10 @@ class OddTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // PHP `%` rejects Stringable with a TypeError, no coercion + return []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/SameasTest.php b/lib/twig/twig/src/Node/Expression/Test/SameasTest.php index f1e24db6f7..cc4d723d07 100644 --- a/lib/twig/twig/src/Node/Expression/Test/SameasTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/SameasTest.php @@ -31,4 +31,10 @@ class SameasTest extends TestExpression ->raw(')') ; } + + public function getStringCoercedChildNames(): array + { + // `===` is strict, no coercion + return []; + } } diff --git a/lib/twig/twig/src/Node/Expression/Test/TrueTest.php b/lib/twig/twig/src/Node/Expression/Test/TrueTest.php index 22186a6898..f0c49d476f 100644 --- a/lib/twig/twig/src/Node/Expression/Test/TrueTest.php +++ b/lib/twig/twig/src/Node/Expression/Test/TrueTest.php @@ -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 []; + } } diff --git a/lib/twig/twig/src/Node/Expression/TestExpression.php b/lib/twig/twig/src/Node/Expression/TestExpression.php index 3b51dd320d..4d81239414 100644 --- a/lib/twig/twig/src/Node/Expression/TestExpression.php +++ b/lib/twig/twig/src/Node/Expression/TestExpression.php @@ -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; + } } diff --git a/lib/twig/twig/src/Node/Expression/Unary/NotUnary.php b/lib/twig/twig/src/Node/Expression/Unary/NotUnary.php index 55c11bacf1..6bdf8800a6 100644 --- a/lib/twig/twig/src/Node/Expression/Unary/NotUnary.php +++ b/lib/twig/twig/src/Node/Expression/Unary/NotUnary.php @@ -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('!'); diff --git a/lib/twig/twig/src/Node/ForNode.php b/lib/twig/twig/src/Node/ForNode.php index 2c86622d47..fda40719f4 100644 --- a/lib/twig/twig/src/Node/ForNode.php +++ b/lib/twig/twig/src/Node/ForNode.php @@ -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") + ; } } diff --git a/lib/twig/twig/src/Node/IfNode.php b/lib/twig/twig/src/Node/IfNode.php index 2c0e2a8e91..c7a7f4b994 100644 --- a/lib/twig/twig/src/Node/IfNode.php +++ b/lib/twig/twig/src/Node/IfNode.php @@ -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) { diff --git a/lib/twig/twig/src/Node/ImportNode.php b/lib/twig/twig/src/Node/ImportNode.php index 92bdd5ebf8..ab7fe0ff50 100644 --- a/lib/twig/twig/src/Node/ImportNode.php +++ b/lib/twig/twig/src/Node/ImportNode.php @@ -23,7 +23,7 @@ use Twig\Node\Expression\Variable\ContextVariable; * @author Fabien Potencier */ #[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']; + } } diff --git a/lib/twig/twig/src/Node/IncludeNode.php b/lib/twig/twig/src/Node/IncludeNode.php index 6e17300f07..3adf415e1b 100644 --- a/lib/twig/twig/src/Node/IncludeNode.php +++ b/lib/twig/twig/src/Node/IncludeNode.php @@ -22,7 +22,7 @@ use Twig\Node\Expression\AbstractExpression; * @author Fabien Potencier */ #[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']; + } } diff --git a/lib/twig/twig/src/Node/ModuleNode.php b/lib/twig/twig/src/Node/ModuleNode.php index 71c5720198..fd43246f4f 100644 --- a/lib/twig/twig/src/Node/ModuleNode.php +++ b/lib/twig/twig/src/Node/ModuleNode.php @@ -28,7 +28,7 @@ use Twig\Source; * @author Fabien Potencier */ #[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() diff --git a/lib/twig/twig/src/Node/PrintNode.php b/lib/twig/twig/src/Node/PrintNode.php index e3c23bbfa1..0eec95d1f1 100644 --- a/lib/twig/twig/src/Node/PrintNode.php +++ b/lib/twig/twig/src/Node/PrintNode.php @@ -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 */ #[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')); + } } diff --git a/lib/twig/twig/src/Node/TextNode.php b/lib/twig/twig/src/Node/TextNode.php index fae65fb2cb..86071dab40 100644 --- a/lib/twig/twig/src/Node/TextNode.php +++ b/lib/twig/twig/src/Node/TextNode.php @@ -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; + } } diff --git a/lib/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php b/lib/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php new file mode 100644 index 0000000000..6fd4e17735 --- /dev/null +++ b/lib/twig/twig/src/NodeVisitor/CorrectnessNodeVisitor.php @@ -0,0 +1,223 @@ + + * + * @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 + */ + 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 + */ + 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; + } +} diff --git a/lib/twig/twig/src/NodeVisitor/SafeAnalysisNodeVisitor.php b/lib/twig/twig/src/NodeVisitor/SafeAnalysisNodeVisitor.php index 8cb5f7a39a..b1aea0f361 100644 --- a/lib/twig/twig/src/NodeVisitor/SafeAnalysisNodeVisitor.php +++ b/lib/twig/twig/src/NodeVisitor/SafeAnalysisNodeVisitor.php @@ -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']; diff --git a/lib/twig/twig/src/NodeVisitor/SandboxNodeVisitor.php b/lib/twig/twig/src/NodeVisitor/SandboxNodeVisitor.php index 9dd48f5be9..dba49c8b08 100644 --- a/lib/twig/twig/src/NodeVisitor/SandboxNodeVisitor.php +++ b/lib/twig/twig/src/NodeVisitor/SandboxNodeVisitor.php @@ -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 @@ -43,7 +46,8 @@ final class SandboxNodeVisitor implements NodeVisitorInterface private $filters; /** @var array */ private $functions; - private $needsToStringWrap = false; + /** @var array */ + 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 diff --git a/lib/twig/twig/src/Parser.php b/lib/twig/twig/src/Parser.php index af54f13b22..5ef1aea8dc 100644 --- a/lib/twig/twig/src/Parser.php +++ b/lib/twig/twig/src/Parser.php @@ -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) diff --git a/lib/twig/twig/src/Profiler/Dumper/BaseDumper.php b/lib/twig/twig/src/Profiler/Dumper/BaseDumper.php index 267718c1f5..06bf4b888a 100644 --- a/lib/twig/twig/src/Profiler/Dumper/BaseDumper.php +++ b/lib/twig/twig/src/Profiler/Dumper/BaseDumper.php @@ -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); diff --git a/lib/twig/twig/src/Profiler/Dumper/HtmlDumper.php b/lib/twig/twig/src/Profiler/Dumper/HtmlDumper.php index cdab2de595..1bfd81b0ea 100644 --- a/lib/twig/twig/src/Profiler/Dumper/HtmlDumper.php +++ b/lib/twig/twig/src/Profiler/Dumper/HtmlDumper.php @@ -30,18 +30,28 @@ final class HtmlDumper extends BaseDumper return '
'.parent::dump($profile).'
'; } + protected function formatRoot(Profile $profile): string + { + return self::escape($profile->getName()); + } + protected function formatTemplate(Profile $profile, $prefix): string { - return \sprintf('%s└ %s', $prefix, self::$colors['template'], $profile->getTemplate()); + return \sprintf('%s└ %s', $prefix, self::$colors['template'], self::escape($profile->getTemplate())); } protected function formatNonTemplate(Profile $profile, $prefix): string { - return \sprintf('%s└ %s::%s(%s)', $prefix, $profile->getTemplate(), $profile->getType(), self::$colors[$profile->getType()] ?? 'auto', $profile->getName()); + return \sprintf('%s└ %s::%s(%s)', $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('%.2fms/%.0f%%', $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'); + } } diff --git a/lib/twig/twig/src/Profiler/Profile.php b/lib/twig/twig/src/Profiler/Profile.php index a3c6ee02e5..63861d653e 100644 --- a/lib/twig/twig/src/Profiler/Profile.php +++ b/lib/twig/twig/src/Profiler/Profile.php @@ -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]])); } /** diff --git a/lib/twig/twig/src/Resources/core.php b/lib/twig/twig/src/Resources/core.php index bc0b27104b..80f62cf698 100644 --- a/lib/twig/twig/src/Resources/core.php +++ b/lib/twig/twig/src/Resources/core.php @@ -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()); } diff --git a/lib/twig/twig/src/Runtime/EscaperRuntime.php b/lib/twig/twig/src/Runtime/EscaperRuntime.php index f686e19f0c..f4a7023c7a 100644 --- a/lib/twig/twig/src/Runtime/EscaperRuntime.php +++ b/lib/twig/twig/src/Runtime/EscaperRuntime.php @@ -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)); } diff --git a/lib/twig/twig/src/Sandbox/SecurityNotAllowedTestError.php b/lib/twig/twig/src/Sandbox/SecurityNotAllowedTestError.php new file mode 100644 index 0000000000..af702ba68e --- /dev/null +++ b/lib/twig/twig/src/Sandbox/SecurityNotAllowedTestError.php @@ -0,0 +1,33 @@ + + */ +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; + } +} diff --git a/lib/twig/twig/src/Sandbox/SecurityPolicy.php b/lib/twig/twig/src/Sandbox/SecurityPolicy.php index b2c83ee106..7d1172bf13 100644 --- a/lib/twig/twig/src/Sandbox/SecurityPolicy.php +++ b/lib/twig/twig/src/Sandbox/SecurityPolicy.php @@ -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); + } } } } diff --git a/lib/twig/twig/src/Sandbox/SecurityPolicyInterface.php b/lib/twig/twig/src/Sandbox/SecurityPolicyInterface.php index 36471c54c2..cb36ba5be3 100644 --- a/lib/twig/twig/src/Sandbox/SecurityPolicyInterface.php +++ b/lib/twig/twig/src/Sandbox/SecurityPolicyInterface.php @@ -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 diff --git a/lib/twig/twig/src/Sandbox/SourcePolicyInterface.php b/lib/twig/twig/src/Sandbox/SourcePolicyInterface.php index b952f1ea6f..1b29608559 100644 --- a/lib/twig/twig/src/Sandbox/SourcePolicyInterface.php +++ b/lib/twig/twig/src/Sandbox/SourcePolicyInterface.php @@ -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 { diff --git a/lib/twig/twig/src/Source.php b/lib/twig/twig/src/Source.php index 0f626b62d3..0c97f255ae 100644 --- a/lib/twig/twig/src/Source.php +++ b/lib/twig/twig/src/Source.php @@ -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; + } } diff --git a/lib/twig/twig/src/Template.php b/lib/twig/twig/src/Template.php index c372092874..3d74460b0e 100644 --- a/lib/twig/twig/src/Template.php +++ b/lib/twig/twig/src/Template.php @@ -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. * diff --git a/lib/twig/twig/src/Token.php b/lib/twig/twig/src/Token.php index 823c773876..0c0e46a81b 100644 --- a/lib/twig/twig/src/Token.php +++ b/lib/twig/twig/src/Token.php @@ -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 */ diff --git a/lib/twig/twig/src/TokenParser/AbstractTokenParser.php b/lib/twig/twig/src/TokenParser/AbstractTokenParser.php index 8acaa6f56e..8c07f60296 100644 --- a/lib/twig/twig/src/TokenParser/AbstractTokenParser.php +++ b/lib/twig/twig/src/TokenParser/AbstractTokenParser.php @@ -34,6 +34,11 @@ abstract class AbstractTokenParser implements TokenParserInterface $this->parser = $parser; } + public function isAlwaysAllowedInSandbox(): bool + { + return false; + } + /** * Parses an assignment expression like "a, b". */ diff --git a/lib/twig/twig/src/TokenParser/ExtendsTokenParser.php b/lib/twig/twig/src/TokenParser/ExtendsTokenParser.php index 8f64698187..cba2927f90 100644 --- a/lib/twig/twig/src/TokenParser/ExtendsTokenParser.php +++ b/lib/twig/twig/src/TokenParser/ExtendsTokenParser.php @@ -12,8 +12,7 @@ namespace Twig\TokenParser; -use Twig\Error\SyntaxError; -use Twig\Node\EmptyNode; +use Twig\Node\ConfigNode; use Twig\Node\Node; use Twig\Token; @@ -29,18 +28,10 @@ final class ExtendsTokenParser extends AbstractTokenParser public function parse(Token $token): Node { $stream = $this->parser->getStream(); - - if ($this->parser->peekBlockStack()) { - throw new SyntaxError('Cannot use "extend" in a block.', $token->getLine(), $stream->getSourceContext()); - } elseif (!$this->parser->isMainScope()) { - throw new SyntaxError('Cannot use "extend" in a macro.', $token->getLine(), $stream->getSourceContext()); - } - - $this->parser->setParent($this->parser->parseExpression()); - + $this->parser->setParent($this->parser->parseExpression(), false); $stream->expect(Token::BLOCK_END_TYPE); - return new EmptyNode($token->getLine()); + return new ConfigNode($token->getLine()); } public function getTag(): string diff --git a/lib/twig/twig/src/TokenParser/MacroTokenParser.php b/lib/twig/twig/src/TokenParser/MacroTokenParser.php index 38e66c8107..7531fe02fe 100644 --- a/lib/twig/twig/src/TokenParser/MacroTokenParser.php +++ b/lib/twig/twig/src/TokenParser/MacroTokenParser.php @@ -13,7 +13,7 @@ namespace Twig\TokenParser; use Twig\Error\SyntaxError; use Twig\Node\BodyNode; -use Twig\Node\EmptyNode; +use Twig\Node\ConfigNode; use Twig\Node\Expression\ArrayExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\Unary\NegUnary; @@ -56,7 +56,7 @@ final class MacroTokenParser extends AbstractTokenParser $this->parser->setMacro($name, new MacroNode($name, new BodyNode([$body]), $arguments, $lineno)); - return new EmptyNode($lineno); + return new ConfigNode($lineno); } public function decideBlockEnd(Token $token): bool diff --git a/lib/twig/twig/src/TokenParser/SandboxTokenParser.php b/lib/twig/twig/src/TokenParser/SandboxTokenParser.php index 536c14f30b..e6f4a63f36 100644 --- a/lib/twig/twig/src/TokenParser/SandboxTokenParser.php +++ b/lib/twig/twig/src/TokenParser/SandboxTokenParser.php @@ -41,7 +41,9 @@ final class SandboxTokenParser extends AbstractTokenParser $stream->expect(Token::BLOCK_END_TYPE); // in a sandbox tag, only include tags are allowed - if (!$body instanceof IncludeNode) { + if ($body instanceof IncludeNode) { + $body->setAttribute('sandboxed', true); + } else { foreach ($body as $node) { if ($node instanceof TextNode && ctype_space($node->getAttribute('data'))) { continue; @@ -50,6 +52,8 @@ final class SandboxTokenParser extends AbstractTokenParser if (!$node instanceof IncludeNode) { throw new SyntaxError('Only "include" tags are allowed within a "sandbox" section.', $node->getTemplateLine(), $stream->getSourceContext()); } + + $node->setAttribute('sandboxed', true); } } diff --git a/lib/twig/twig/src/TokenParser/TokenParserInterface.php b/lib/twig/twig/src/TokenParser/TokenParserInterface.php index bb8db3e5cc..ae78714f57 100644 --- a/lib/twig/twig/src/TokenParser/TokenParserInterface.php +++ b/lib/twig/twig/src/TokenParser/TokenParserInterface.php @@ -20,6 +20,8 @@ use Twig\Token; * Interface implemented by token parsers. * * @author Fabien Potencier + * + * @method bool isAlwaysAllowedInSandbox() Whether the tag is always allowed in sandbox mode, even when not explicitly allow-listed. Not implementing this method is deprecated since Twig 3.28, it will be required in 4.0. */ interface TokenParserInterface { diff --git a/lib/twig/twig/src/TokenParser/UseTokenParser.php b/lib/twig/twig/src/TokenParser/UseTokenParser.php index 41386c8b47..a85b023081 100644 --- a/lib/twig/twig/src/TokenParser/UseTokenParser.php +++ b/lib/twig/twig/src/TokenParser/UseTokenParser.php @@ -12,7 +12,7 @@ namespace Twig\TokenParser; use Twig\Error\SyntaxError; -use Twig\Node\EmptyNode; +use Twig\Node\ConfigNode; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Node; use Twig\Node\Nodes; @@ -65,7 +65,7 @@ final class UseTokenParser extends AbstractTokenParser $this->parser->addTrait(new Nodes(['template' => $template, 'targets' => new Nodes($targets)])); - return new EmptyNode($token->getLine()); + return new ConfigNode($token->getLine()); } public function getTag(): string diff --git a/lib/twig/twig/src/TokenStream.php b/lib/twig/twig/src/TokenStream.php index 7ee7539f1a..2586750faf 100644 --- a/lib/twig/twig/src/TokenStream.php +++ b/lib/twig/twig/src/TokenStream.php @@ -83,7 +83,8 @@ final class TokenStream $token->getValue() ? \sprintf(' of value "%s"', $token->getValue()) : '', Token::typeToEnglish($type), $value ? \sprintf(' with value "%s"', $value) : ''), $line, - $this->source + $this->source, + columnno: $this->source->getColumn($token->getOffset() ?? -1), ); } $this->next(); diff --git a/lib/twig/twig/src/TwigCallableInterface.php b/lib/twig/twig/src/TwigCallableInterface.php index 2a8ff6116b..06b0e2e957 100644 --- a/lib/twig/twig/src/TwigCallableInterface.php +++ b/lib/twig/twig/src/TwigCallableInterface.php @@ -13,6 +13,9 @@ namespace Twig; /** * @author Fabien Potencier + * + * @method bool needsIsSandboxed() Whether the callable needs the current sandbox state passed as an argument. Not implementing this method is deprecated since Twig 3.25, it will be required in 4.0. + * @method bool isAlwaysAllowedInSandbox() Whether the callable is always allowed in sandbox mode, even when not explicitly allow-listed. Not implementing this method is deprecated since Twig 3.28, it will be required in 4.0. */ interface TwigCallableInterface extends \Stringable { diff --git a/lib/twig/twig/src/Util/CallableArgumentsExtractor.php b/lib/twig/twig/src/Util/CallableArgumentsExtractor.php index d8625169d7..e3e1f82bd7 100644 --- a/lib/twig/twig/src/Util/CallableArgumentsExtractor.php +++ b/lib/twig/twig/src/Util/CallableArgumentsExtractor.php @@ -183,22 +183,7 @@ final class CallableArgumentsExtractor private function getCallableParameters(): array { - $parameters = $this->rc->getReflector()->getParameters(); - if ($this->node->hasNode('node')) { - array_shift($parameters); - } - if ($this->twigCallable->needsCharset()) { - array_shift($parameters); - } - if ($this->twigCallable->needsEnvironment()) { - array_shift($parameters); - } - if ($this->twigCallable->needsContext()) { - array_shift($parameters); - } - foreach ($this->twigCallable->getArguments() as $argument) { - array_shift($parameters); - } + $parameters = $this->rc->getTwigParameters($this->node->hasNode('node')); $isPhpVariadic = false; if ($this->twigCallable->isVariadic()) { diff --git a/lib/twig/twig/src/Util/CallableParameters.php b/lib/twig/twig/src/Util/CallableParameters.php new file mode 100644 index 0000000000..4321bb2915 --- /dev/null +++ b/lib/twig/twig/src/Util/CallableParameters.php @@ -0,0 +1,161 @@ + + * + * @internal + */ +final class CallableParameters +{ + /** + * Returns the PHP parameters of a Filter/Function/Test call mapped to its + * template-level arguments. + * + * The parameters Twig injects automatically + * (`needs_charset/environment/context/is_sandboxed`) and the bound + * `arguments` are stripped. For a filter, the first returned parameter is + * the filter's input value. Returns null when reflection fails or the node + * is not a callable expression. + * + * @return list<\ReflectionParameter>|null + */ + public static function fromNode(Node $node, Environment $env): ?array + { + if (!$node instanceof FilterExpression && !$node instanceof FunctionExpression && !$node instanceof TestExpression) { + return null; + } + + $callable = self::resolveTwigCallable($node, $env); + if (null === $callable || null === $callable->getCallable()) { + return null; + } + + try { + return (new ReflectionCallable($callable))->getTwigParameters(); + } catch (\LogicException) { + return null; + } + } + + /** + * Returns true when a PHP parameter type proves the value reaching it + * cannot implicitly string-coerce (directly or by iterating it). + * + * Safe: `int`, `float`, `bool`, `null`, `false`, `true`, `void`, `never`, + * enums, and `final` class types that are neither `Stringable` nor + * `Traversable`. + * + * Unsafe: `string`, `array`, `iterable`, `mixed`, `object`, + * untyped, interfaces, non-final classes, and any type that is + * `Stringable` or `Traversable`. Interfaces and non-final classes are + * open: a subtype could add `Stringable`/`Traversable` and reach the host + * code, bypassing the `__toString` policy, so only a `final` class (enums + * included) is closed enough. + * + * @param \ReflectionClass|null $scope resolves the relative types `self`/`parent`/`static` (which + * older PHP versions report verbatim instead of the declaring class) + */ + public static function isStringCoercionSafe(?\ReflectionType $type, ?\ReflectionClass $scope = null): bool + { + if (null === $type) { + return false; + } + + // A union value is one of its members, so every member must be safe. + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $t) { + if (!self::isStringCoercionSafe($t, $scope)) { + return false; + } + } + + return true; + } + + // An intersection value satisfies all its members at once. A safe + // member is necessarily a final class, which pins the concrete class, + // so the value is that non-coercible class whatever the other members. + if ($type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if (self::isStringCoercionSafe($t, $scope)) { + return true; + } + } + + return false; + } + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $name = $type->getName(); + + if ($type->isBuiltin()) { + return match ($name) { + // `null` (e.g. as a union member) cannot have a __toString. + 'null', 'int', 'float', 'bool', 'true', 'false', 'void', 'never' => true, + default => false, // string, array, iterable, object, mixed, callable + }; + } + + // Resolve `self`/`parent`/`static` to the concrete class + // `static` is treated like `self` since a `final` class cannot be subclassed anyway + $class = match ($name) { + 'self', 'static' => $scope, + 'parent' => $scope ? ($scope->getParentClass() ?: null) : null, + default => class_exists($name, false) ? new \ReflectionClass($name) : null, + }; + + // Interfaces and non-final classes are open: a subtype could add + // Stringable/Traversable, so only a final non-coercible class is safe + if (null === $class) { + return false; + } + if (is_a($class->getName(), \Stringable::class, true) || is_a($class->getName(), \Traversable::class, true)) { + return false; + } + + return $class->isFinal(); + } + + private static function resolveTwigCallable(Node $node, Environment $env): ?TwigCallableInterface + { + if ($node->hasAttribute('twig_callable')) { + return $node->getAttribute('twig_callable'); + } + if (!$node->hasAttribute('name')) { + return null; + } + $name = $node->getAttribute('name'); + try { + return match (true) { + $node instanceof FilterExpression => $env->getFilter($name), + $node instanceof FunctionExpression => $env->getFunction($name), + $node instanceof TestExpression => $env->getTest($name), + }; + } catch (\Throwable) { + return null; + } + } +} diff --git a/lib/twig/twig/src/Util/DeprecationCollector.php b/lib/twig/twig/src/Util/DeprecationCollector.php index 0ea26ed4ba..64cbba5a3f 100644 --- a/lib/twig/twig/src/Util/DeprecationCollector.php +++ b/lib/twig/twig/src/Util/DeprecationCollector.php @@ -54,7 +54,7 @@ final class DeprecationCollector public function collect(\Traversable $iterator): array { $deprecations = []; - set_error_handler(function ($type, $msg) use (&$deprecations) { + set_error_handler(static function ($type, $msg) use (&$deprecations) { if (\E_USER_DEPRECATED === $type) { $deprecations[] = $msg; } diff --git a/lib/twig/twig/src/Util/ReflectionCallable.php b/lib/twig/twig/src/Util/ReflectionCallable.php index 0298e291de..9616e3b298 100644 --- a/lib/twig/twig/src/Util/ReflectionCallable.php +++ b/lib/twig/twig/src/Util/ReflectionCallable.php @@ -11,6 +11,7 @@ namespace Twig\Util; +use Twig\Node\Expression\CallExpression; use Twig\TwigCallableInterface; /** @@ -25,7 +26,7 @@ final class ReflectionCallable private $name; public function __construct( - TwigCallableInterface $twigCallable, + private TwigCallableInterface $twigCallable, ) { $callable = $twigCallable->getCallable(); if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { @@ -80,6 +81,41 @@ final class ReflectionCallable return $this->reflector; } + /** + * Returns the PHP parameters that map to the callable's template-level + * arguments. + * + * The parameters Twig injects automatically (the piped input value when + * $stripInput is true, then needs_charset/environment/context/is_sandboxed) + * and the bound arguments are stripped. + * + * @return list<\ReflectionParameter> + */ + public function getTwigParameters(bool $stripInput = false): array + { + $parameters = $this->reflector->getParameters(); + if ($stripInput) { + array_shift($parameters); + } + if ($this->twigCallable->needsCharset()) { + array_shift($parameters); + } + if ($this->twigCallable->needsEnvironment()) { + array_shift($parameters); + } + if ($this->twigCallable->needsContext()) { + array_shift($parameters); + } + if (CallExpression::needsIsSandboxed($this->twigCallable)) { + array_shift($parameters); + } + foreach ($this->twigCallable->getArguments() as $argument) { + array_shift($parameters); + } + + return array_values($parameters); + } + /** * @return callable */