What happened?
Description
BaseEventTagVisitor tracks whether head(), beginBody(), and endBody() have been found/added using process-global static properties that are never reset between template compilations. Twig compiles a child template before its parent. Therefore: if a child template that contains its own literal </head> / </body> (e.g. an error template that overrides the layout's outer block with a standalone document) is compiled first on a cold compiled-template cache, the statics flip to true, and the parent layout is then compiled — and persisted to disk — without any event-tag injection.
From that moment, every page rendered through that layout silently drops all registered head/body HTML (registerMetaTag(), registerLinkTag(), registerJs(), plugin output such as SEOmatic's meta containers and JSON-LD) until the compiled-template cache is cleared again. There is no error, no log entry, and the pages otherwise render perfectly.
The failure is intermittent and race-driven: after every deploy or cache purge, each origin container compiles templates on demand. Whichever request first touches the layout chain decides the outcome. Real-world 404 noise (scanners, dead links) is constant, so on each fresh container there is a race between "first request is a 404" (poisons the layout) and "first request is a real page" (compiles it correctly). We measured ~0.5–1.5% of fresh renders coming out poisoned during a post-purge crawl — each of which then gets cached by the CDN for its TTL, accumulating over weeks into the 25% figure.
This class of bug is nearly impossible for site owners to diagnose: it depends on request arrival order at compile time, produces no errors, and self-heals on any compile-cache clear — until it re-poisons.
Steps to reproduce
-
The statics are process-global and never reset.
src/web/twig/nodevisitors/BaseEventTagVisitor.php (lines 20–34):
protected static bool $foundHead = false;
protected static bool $foundBeginBody = false;
protected static bool $foundEndBody = false;
They are set to true by EventTagFinder (when an explicit head() etc. call is found, line 55) and by EventTagAdder (when it injects one, line 95). Nothing ever sets them back to false — not per template, not per render, not per request phase.
-
EventTagAdder skips work once the statics are set.
src/web/twig/nodevisitors/EventTagAdder.php, enterNode():
if ($node instanceof TextNode && !static::foundAllEventTags()) {
$node = $this->_processTextNode($node, $env);
}
and _processTextNode() starts with if (static::$foundHead === false && ...).
-
Twig compiles the child before the parent.
With {% extends %}, the parent template is resolved at runtime of the child's display() (twig/twig src/Template.php, getParent() call inside display()/yield). So on a cold compiled-template cache, the compilation order within one request is: child first, parent second.
-
The poisoning scenario. A child template that overrides the layout's outer block with a complete standalone document — a very common pattern for error pages — contains its own </head>, <body>, </body> as literal TextNode data:
{# 404.twig #}
{% extends "_layouts/base" %}
{% block base %}
<!doctype html>
<html>
<head>
...
</head>
<body>...</body>
</html>
{% endblock %}
Compiling this child sets all three statics to true (EventTagAdder correctly injects the event tags into the child's compiled output). Immediately afterwards, in the same request, Twig compiles the parent _layouts/base.twig — and EventTagAdder never processes it, because foundAllEventTags() is already true. The parent's compiled PHP is written to storage/runtime/compiled_templates/ without the getFunction('head')->getCallable()() calls.
-
The poisoned compiled file persists. Compiled templates are cached until source mtime changes or the cache is cleared. Every subsequent page render through this layout — in any request, by any user — skips the placeholder output, so yii\web\View::endPage()'s strtr() has nothing to replace and all buffered head/body HTML is discarded silently.
Note that adding explicit event tags to the child does not help: EventTagFinder sets the same statics when it finds explicit calls (line 55), so the parent still compiles without injection. The state sharing between separately-cached compilation artifacts is unsound in principle: template A may be compiled today and render next to a layout compiled last week — no compile-time decision about one file should depend on which other file happened to compile earlier in the same process.
Craft CMS version
5.10.11
PHP version
8.4.22
Operating system and version
Craft Cloud
Database type and version
No response
Image driver and version
No response
Installed plugins and versions
"craftcms/aws-s3": "^2.3.1",
"craftcms/ckeditor": "^4.11.1",
"craftcms/cloud": "*",
"craftcms/cms": "^5.10.11",
"craftcms/feed-me": "^6.14.0",
"mutation/translate": "^4.2.3",
"nystudio107/craft-retour": "^5.0.14",
"nystudio107/craft-seomatic": "5.1.21",
"nystudio107/craft-vite": "^5.0.0",
"putyourlightson/craft-dashboard-begone": "^3.0.0",
"putyourlightson/craft-elements-panel": "^3.0.0",
"putyourlightson/craft-sendgrid": "^3.0.0",
"putyourlightson/craft-sherlock": "^5.2.1",
"putyourlightson/craft-sprig": "^3.7.3",
"spacecatninja/imager-x": "^6.1.0",
"spacecatninja/imager-x-craft-cloud-transformer": "^1.0.2",
"spacecatninja/imager-x-power-pack": "^1.1.3",
"swishdigital/template-selector": "^5.0.1",
"verbb/field-manager": "^4.0.4",
"verbb/formie": "^3.1.33",
"verbb/hyper": "^2.3.11",
"verbb/navigation": "^3.0.22",
"verbb/vizy": "^3.2.2",
"vlucas/phpdotenv": "^5.4.0",
"yiisoft/yii2-redis": "^2.0.0"
What happened?
Description
BaseEventTagVisitortracks whetherhead(),beginBody(), andendBody()have been found/added using process-global static properties that are never reset between template compilations. Twig compiles a child template before its parent. Therefore: if a child template that contains its own literal</head>/</body>(e.g. an error template that overrides the layout's outer block with a standalone document) is compiled first on a cold compiled-template cache, the statics flip totrue, and the parent layout is then compiled — and persisted to disk — without any event-tag injection.From that moment, every page rendered through that layout silently drops all registered head/body HTML (
registerMetaTag(),registerLinkTag(),registerJs(), plugin output such as SEOmatic's meta containers and JSON-LD) until the compiled-template cache is cleared again. There is no error, no log entry, and the pages otherwise render perfectly.The failure is intermittent and race-driven: after every deploy or cache purge, each origin container compiles templates on demand. Whichever request first touches the layout chain decides the outcome. Real-world 404 noise (scanners, dead links) is constant, so on each fresh container there is a race between "first request is a 404" (poisons the layout) and "first request is a real page" (compiles it correctly). We measured ~0.5–1.5% of fresh renders coming out poisoned during a post-purge crawl — each of which then gets cached by the CDN for its TTL, accumulating over weeks into the 25% figure.
This class of bug is nearly impossible for site owners to diagnose: it depends on request arrival order at compile time, produces no errors, and self-heals on any compile-cache clear — until it re-poisons.
Steps to reproduce
The statics are process-global and never reset.
src/web/twig/nodevisitors/BaseEventTagVisitor.php(lines 20–34):They are set to
truebyEventTagFinder(when an explicithead()etc. call is found, line 55) and byEventTagAdder(when it injects one, line 95). Nothing ever sets them back tofalse— not per template, not per render, not per request phase.EventTagAdderskips work once the statics are set.src/web/twig/nodevisitors/EventTagAdder.php,enterNode():and
_processTextNode()starts withif (static::$foundHead === false && ...).Twig compiles the child before the parent.
With
{% extends %}, the parent template is resolved at runtime of the child'sdisplay()(twig/twigsrc/Template.php,getParent()call insidedisplay()/yield). So on a cold compiled-template cache, the compilation order within one request is: child first, parent second.The poisoning scenario. A child template that overrides the layout's outer block with a complete standalone document — a very common pattern for error pages — contains its own
</head>,<body>,</body>as literalTextNodedata:Compiling this child sets all three statics to
true(EventTagAdder correctly injects the event tags into the child's compiled output). Immediately afterwards, in the same request, Twig compiles the parent_layouts/base.twig— andEventTagAddernever processes it, becausefoundAllEventTags()is alreadytrue. The parent's compiled PHP is written tostorage/runtime/compiled_templates/without thegetFunction('head')->getCallable()()calls.The poisoned compiled file persists. Compiled templates are cached until source mtime changes or the cache is cleared. Every subsequent page render through this layout — in any request, by any user — skips the placeholder output, so
yii\web\View::endPage()'sstrtr()has nothing to replace and all buffered head/body HTML is discarded silently.Note that adding explicit event tags to the child does not help:
EventTagFindersets the same statics when it finds explicit calls (line 55), so the parent still compiles without injection. The state sharing between separately-cached compilation artifacts is unsound in principle: template A may be compiled today and render next to a layout compiled last week — no compile-time decision about one file should depend on which other file happened to compile earlier in the same process.Craft CMS version
5.10.11
PHP version
8.4.22
Operating system and version
Craft Cloud
Database type and version
No response
Image driver and version
No response
Installed plugins and versions