Skip to content

[5.x]: EventTagAdder's never-reset static state can compile layouts without head()/beginBody()/endBody() — silently dropping all registered head/body HTML #19304

Description

@jan-dh

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

  1. 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.

  2. 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 && ...).

  3. 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.

  4. 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.

  5. 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"

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions