Twig 3.26.0 is a security release fixing 13 advisories, with two rated critical, three high, four medium, and four low. Almost all of them target the sandbox, the component that lets applications run untrusted templates under an explicit allow-list of tags, filters, functions, properties, and methods. All users running untrusted templates through the sandbox should upgrade immediately.

This blog post walks through the issues, grouped by impact, so you can assess your exposure. Each section ends with the CVE identifier; the full list, including links to the GitHub Security Advisories, is at the bottom of the post.

Critical: PHP code injection through the compiled cache

Two distinct paths allowed a template author to inject arbitrary PHP code into the generated cache file, which is then executed in the host process the first time the template is rendered.

The first one is the _self.(<string>) dynamic-attribute syntax. When the receiver is _self (or any {% import %} alias) and the parenthesised expression is a string literal, the parser short-circuits to the macro-call path and concatenates the attacker-controlled string into a macro reference with no identifier validation. The compiler then emits that name raw into the generated PHP source:

1
2
{# arbitrary PHP injected through the macro name #}
{{ _self.('attacker controlled string') }}

The fix validates that the dynamic attribute resolves to a valid macro identifier before the macro-call path is taken (CVE-2026-46640).

The second one targets {% use %}. Compiler::string() escaped ", $, \, NUL and TAB when generating PHP double-quoted string literals, but did not escape single quotes. The template name from a {% use %} tag was placed inside a surrounding PHP single-quoted literal, so a name containing a single quote terminated that literal early and let arbitrary PHP expressions slip into the cache file. As a defense-in-depth measure, Compiler::string() now also encodes single quotes as \x27 (CVE-2026-46633).

High: sandbox bypasses

Three issues let a sandboxed template reach data and behavior that the security policy should have blocked.

The object-destructuring assignment added in 3.24.0 was generating a call to CoreExtension::getAttribute() with the $sandboxed argument hardcoded to false, regardless of whether a SandboxExtension was active. This silently turned off property and method allow-list checks for every destructuring expression:

1
2
{# both property reads were unchecked in 3.x prior to 3.26 #}
{name: userName, email: userEmail} = user

The compiler now passes the correct sandbox flag (CVE-2026-46639).

A second class of bypasses came from __toString() coercions that the sandbox never observed. SandboxNodeVisitor used to wrap a hardcoded list of node types with a CheckToStringNode; everything else, including ternaries, matches, comparison operators, range expressions, the do tag, dynamic attribute names, spread arguments and the is empty test, coerced Stringable operands to string with no policy check. A sandboxed template author could therefore call __toString() on any object reachable in the render context, even when the method was not allow-listed. The fix introduces a new Twig\Node\CoercesChildrenToStringInterface so each node declares which of its children must be guarded, and the visitor wraps them systematically. Spread arguments are materialized and policy-checked through SandboxExtension::ensureSpreadAllowed(), and dynamic attribute names are checked at runtime inside CoreExtension::getAttribute() (CVE-2026-47732).

Finally, when the sandbox was enabled selectively through a SourcePolicyInterface, the runtime check that forbids non-Closure callbacks in sort, filter, map and reduce did not consider the current template Source and treated the call as non-sandboxed. checkArrow() now receives the Source so the source policy is correctly consulted (CVE-2026-24425).

Medium: more sandbox holes

The column filter passed its input straight to PHP's native array_column(). When the array elements are objects, that function reads $obj->$name directly, including invoking __get and __isset. Because the read happens entirely in native code, it never reached CoreExtension::getAttribute() and the property allow-list was never consulted. The filter is now sandbox-aware and routes property reads through the regular attribute machinery (CVE-2026-46635).

The fix for CVE-2024-45411 added an explicit $loaded->unwrap()->checkSecurity() in CoreExtension::include() so templates cached in Environment::$loadedTemplates are re-checked when included with sandboxed: true. The deprecated but still functional {% sandbox %}{% include %}{% endsandbox %} tag path was missed: if the included template had been loaded once outside the sandbox, the second include skipped checkSecurity() entirely. The tag now performs the same recheck (CVE-2026-46638).

When the sandbox was enabled selectively via SourcePolicyInterface, a template allowed to call template_from_string and include could render an arbitrary inner template with no security policy applied. Environment::createTemplate() synthesizes a name like __string_template__<hash> for which a name-based source policy returns false, so the inner template's checkSecurity() became a no-op. The documentation now spells out this caveat and the recommendation to enable the sandbox globally when accepting truly untrusted input (CVE-2026-46634).

The sandbox protects against information disclosure and unsafe operations, not against resource exhaustion: a sandboxed template can still burn CPU, memory, or wall-clock time through large ranges, nested loops, recursive macros, deep includes, and so on. This is a deliberate design choice and is now explicitly documented so integrators size their guarantees accordingly (CVE-2026-46627).

Low: escaping and memoisation

The spaceless filter was registered with is_safe => ['html'], which told the autoescaper not to escape its output in an HTML context. Applying spaceless to attacker-controlled markup therefore emitted that markup unescaped, even when the developer never wrote |raw and autoescape was enabled. The filter now pre-escapes its input so the output is safe to mark as HTML (CVE-2026-46628).

The same pattern affected several HTML-emitting filters in the extras: inline_css and inky_to_html in twig/cssinliner-extra and twig/inky-extra were declared is_safe => ['html'] but did not escape their input, and html_to_markdown and markdown_to_html in twig/markdown-extra were declared is_safe => ['all'] even though their output is plain Markdown or HTML, not safe in every escaping context. The is_safe annotations are now correct and inputs are pre-escaped where needed (CVE-2026-46637).

Twig\Profiler\Dumper\HtmlDumper wrote Profile::getTemplate() and Profile::getName() straight into its HTML output without escaping. When the template name is attacker-controlled (an ArrayLoader key, a database row id, etc.), browsing the profiler dump executed arbitrary HTML. Both values are now htmlspecialchars-escaped (CVE-2026-47730).

Finally, IntlExtension memoised every IntlDateFormatter and NumberFormatter it created in instance-level arrays, keyed on locale, pattern, attrs and other named arguments of the format_datetime / format_date / format_time / format_number / format_currency filters. There was no size limit and no eviction, so a template that iterates over many distinct pattern (or locale, etc.) values could pin one ICU formatter object per distinct value for the entire lifetime of the Twig\Environment. The cache now has a bounded size with LRU eviction (CVE-2026-46629).

Credits

Thanks to the reporters who responsibly disclosed these issues: Claude Mythos Preview (via Project Glasswing), El Kharoubi Iosif, Kai Aizen (Snailsploit), Christophe Coevoet, Anvil Secure (in collaboration with Claude and Anthropic Research), Pierre Rudloff, and Nicolò Ribaudo.

Fixes were authored by Fabien Potencier, Alexandre Daubois, Nicolas Grekas, Claude Mythos Preview (via Project Glasswing), and Anvil Secure (in collaboration with Claude and Anthropic Research). Thanks to everyone who helped investigate, review, and coordinate the disclosure.

Full Changelog

  • CVE-2026-46640 Arbitrary PHP code execution via _self.(<string>) macro-reference compilation
  • CVE-2026-46633 PHP code injection via {% use %} template name
  • CVE-2026-47732 Sandbox: multiple __toString() policy bypasses via unguarded string coercion points
  • CVE-2026-46639 Sandbox property and method bypass via object-destructuring assignment
  • CVE-2026-24425 Possible sandbox bypass when using a source policy
  • CVE-2026-46635 Sandbox property allowlist bypass via the column filter (array_column on objects)
  • CVE-2026-46638 {% sandbox %}{% include %} skips checkSecurity() on cached templates (incomplete fix for CVE-2024-45411)
  • CVE-2026-46634 template_from_string() escapes a SourcePolicy-driven sandbox via synthesized template name
  • CVE-2026-46627 Sandbox does not protect against resource exhaustion
  • CVE-2026-46628 The spaceless filter implicitly marks its output as safe
  • CVE-2026-46637 HTML-output filters in twig/* extras incorrectly declared is_safe => ['all']
  • CVE-2026-47730 XSS in profiler HtmlDumper via unescaped template and profile names
  • CVE-2026-46629 Unbounded formatter memoisation in twig/intl-extra keyed on template-controlled arguments