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
columnfilter (array_columnon objects) - CVE-2026-46638
{% sandbox %}{% include %}skipscheckSecurity()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
spacelessfilter implicitly marks its output as safe - CVE-2026-46637 HTML-output filters in
twig/*extras incorrectly declaredis_safe => ['all'] - CVE-2026-47730 XSS in profiler
HtmlDumpervia unescaped template and profile names - CVE-2026-46629 Unbounded formatter memoisation in
twig/intl-extrakeyed on template-controlled arguments