Drupal
Drupal 9 cache poisoning: CDN-purge lekt admin-pagina
Om 18:47 op een dinsdag kwam er een appje binnen: de Drupal 9-homepage van een klant serveerde edit-links aan het hele internet. Dit is wat negen uur graven opleverde.

Het was 18:47 op een dinsdag toen de WhatsApp van de klant oplichtte. Drie berichten achter elkaar, allemaal varianten op dezelfde screenshot: de Drupal 9-homepage met een "Edit"-link naast elke producttegel, een "Operations"-dropdown in de header, en een sidebar met de namen van drie interne redacteuren. Geserveerd aan het hele internet.
De negen uur die volgden zaten wij met z'n tweeën in een Meet-call met de lead developer van de klant, terugwerkend van een gecachte response naar een race condition naar een preprocess-hook van één regel die bijna twee jaar lang ongemerkt admin-context had gelekt.
De eerste tien minuten
De reproductie was direct. Incognito-venster, vers IP via tethering, geen cookies: dezelfde homepage met admin-spul erop. Dus geen session-leak. Een cache-leak.
De response headers vertelden de rest:
HTTP/2 200
x-cache: HIT
x-served-by: cache-ams21034-AMS
x-cache-hits: 847
x-drupal-cache: HIT
age: 1162
847 hits in minder dan twintig minuten. Fastly serveerde vrolijk de render van een ingelogde admin aan iedereen die op de homepage landde.
Eerste instinct: Fastly purgen. Gedaan. Refresh. Dezelfde pagina was binnen vier seconden terug. Wat het ook vergiftigde, het zat upstream en herstelde zichzelf.
Waar de voor de hand liggende fix faalde
We doorliepen het standaard rijtje. drush cr om de caches van Drupal te legen. TRUNCATE op de tabellen cache_render en cache_dynamic_page_cache. Een Fastly-purge per surrogate key. Daarna een volledige Fastly purge-all.
Elke keer kwam de foute response binnen seconden terug. Soms binnen milliseconden. De render-cache werd vrijwel direct na elke flush opnieuw beschreven met dezelfde vergiftigde HTML.
Het patroon klopt alleen als je accepteert dat iemand of iets in het venster tussen de flush en de volgende anonieme hit een request deed als ingelogde admin, en dat de response van dat admin-request werd geschreven naar een cache die anonieme bezoekers vervolgens uitlazen.
Als een render-cache-lookup ooit content teruggeeft die onder de rechten van een andere gebruiker is opgebouwd, heb je geen caching-bug. Je hebt een permissions-bug die door de cache wereldwijd is gemaakt.
De vergiftigde response traceren
We voegden een debug-header aan de response toe zodat we konden zien welke cache tags en contexts de gerenderde pagina meedroeg. Drupal maakt dat makkelijk als je de dev services aanzet:
# sites/default/services.yml
parameters:
http.response.debug_cacheability_headers: true
De volgende foute response vertelde ons alles:
x-drupal-cache-tags: block_view config:block.block.homepage_curated
node:1247 node:1289 node:1301 user:42 user_view
x-drupal-cache-contexts: languages:language_interface url.path
Twee dingen vielen op. De response droeg user:42 als cache tag mee, wat betekende dat er tijdens de render een user-entity was aangeraakt. En de lijst met cache contexts noemde alleen languages:language_interface en url.path. Geen user.roles. Geen user.permissions. Geen user.
In het cache-model van Drupal is die combinatie een bekentenis. De response hangt af van een specifieke gebruiker, maar wordt gesleuteld alsof hij van niets over die gebruiker afhangt. Dus de eerste render wint, en elk volgend request met een matchende URL en taal leest hem terug.
Het blok in het hart van de bug
De cache tag user:42 leidde ons naar een custom block dat in een Layout Builder-sectie op de homepage stond. Het blok was een Views-display, "Curated products", geconfigureerd met een "Global: Custom text"-header. De header was het smoking gun:
{# views-view--curated-products.html.twig #}
{% if user.hasPermission('administer nodes') %}
<a href="{{ path('entity.node.edit_form', {node: row.nid}) }}">Edit</a>
{% endif %}
De template doet het juiste soort check. Het probleem is wat er een laag hoger ontbreekt. De getCacheContexts() van het block-plugin verklaarde nooit dat de render afhing van de rollen of permissies van de huidige gebruiker:
// modules/custom/abn_homepage/src/Plugin/Block/CuratedProductsBlock.php
public function getCacheContexts() {
return Cache::mergeContexts(
parent::getCacheContexts(),
['url.path', 'languages:language_interface']
);
}
Dat is de hele bug. Het blok rendert voorwaardelijk een link die alleen voor admins is, maar vertelt het cache-systeem "sla dit één keer per URL op, ongeacht wie het bekijkt". Drupal doet wat je vraagt. De render met de Edit-link wordt opgeslagen. De volgende anonieme bezoeker krijgt hem terug.
De race die de cache schreef
Het stuk dat we nog moesten begrijpen was de timing. Waarom nu? Het blok stond al bijna twee jaar live. We trokken de deploy-log erbij.
2026-06-02 18:00:03 deploy.sh start
2026-06-02 18:00:11 drush updb -y
2026-06-02 18:00:14 drush cr
2026-06-02 18:00:15 fastly purge --all
2026-06-02 18:00:17 deploy.sh ok
Om 18:00 purgde het deploy-script Fastly als onderdeel van de release. Het eerstvolgende request naar de homepage kwam om 18:00:19, van een redacteur die ingelogd was gebleven om te controleren of de nieuwe hero-image goed was geland. De session-warmed render van die redacteur kwam binnen op een koude Drupal-cache, vulde hem, en de edge van Fastly cachte de upstream-response onder de URL van de homepage.
De afgelopen twee jaar was de homepage bijna nooit de eerste hit na een purge. Redacteuren controleerden deploys meestal door direct naar productpagina's te linken. Deze keer ging die redacteur naar de homepage. De race werd gewonnen door de slechtst denkbare kandidaat.
De eerste cache miss na een purge is een geprivilegieerd moment. Wie die race wint, schrijft het antwoord voor alle anderen.
Cache contexts zijn een contract
De Drupal Cache API is een van de beter ontworpen stukken van het framework, en is expliciet over precies dit faalpatroon. De officiële cache contexts-documentatie verwoordt de regel klip en klaar: alles waarop een render varieert, moet je verklaren, anders hergebruikt de cache hem in situaties waar dat niet zou moeten.
De bedoeling is dat elke render array, elk blok en elke controller-response de volledige set inputs verklaart waar zijn output van afhangt. Rollen. Permissies. Query string. Theme. Taal. Alles wat de bytes verandert die je verstuurt.
Het contract wordt op precies één plek afgedwongen: het toetsenbord van de developer. Er is geen statische check die bevestigt dat een {% if user.hasPermission(...) %}-blok in een template gepaard gaat met een bijbehorende user.permissions-entry in getCacheContexts(). Het render-systeem vertrouwt op wat jij hem vertelt.
Dit is het soort bug dat de meeste code reviews en automated scanners ontsnapt, omdat het symptoom alleen optreedt wanneer de cache, de CDN en een geprivilegieerde gebruiker in de verkeerde volgorde op elkaar aansluiten. Geen van die stukken is op zichzelf fout.
De fix op de applicatielaag
De patch was drie regels. Voeg de twee ontbrekende contexts toe, en forceer een re-render voor gebruikers wier permissies de output kunnen veranderen:
public function getCacheContexts() {
return Cache::mergeContexts(
parent::getCacheContexts(),
['url.path', 'languages:language_interface',
'user.roles', 'user.permissions']
);
}
Met user.permissions in de lijst varieert Drupal de cache key van de render op een hash van de permissies van de bezoeker. Anonieme gebruikers delen één cache-entry. Redacteuren een tweede. Admins een derde. Niemand leest andermans bytes.
We hebben de admin-link bovendien naar een #lazy_builder verplaatst. Lazy builders zijn placeholders die Drupal per request evalueert, na de hoofd-render-cache-lookup. De dure gecachte schil kan over alle bezoekers worden hergebruikt, terwijl het kleine per-user fragment elke keer opnieuw wordt opgebouwd. Daar is BigPipe voor ontworpen.
$build['edit_link'] = [
'#lazy_builder' => [
'abn_homepage.lazy_builders:editLink',
[$node->id()],
],
'#create_placeholder' => TRUE,
];
De fix aan de edge
De applicatie-fix dicht de bug. De edge-fix zorgt dat een toekomstige variant niet 847 hits haalt voor iemand het doorheeft.
We veranderden twee dingen in de VCL van Fastly. Eén: cache nooit een response met een session cookie:
sub vcl_fetch {
if (beresp.http.Set-Cookie ~ "^S?SESS[a-f0-9]+=") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
}
Twee: strip session cookies van requests die anoniem horen te zijn, zodat ze zichzelf niet per ongeluk tot ingelogd kunnen promoveren:
sub vcl_recv {
if (req.url !~ "^/(user|admin|node/\d+/edit)") {
unset req.http.Cookie;
}
}
De tweede regel is agressief en we deployen hem alleen op sites waar de anonieme en de ingelogde experience netjes gescheiden zijn. Op de site van deze klant was dat zo, dus hij ging mee.
Wat we nu op elke Drupal-site controleren
De post-mortem leverde vier regels op die we nu afdwingen op elke Drupal-site onder ons beheer, contrib of custom.
Eén. Elk block-plugin, elke controller en elke render array die voorwaardelijk content rendert op basis van de bekijkende gebruiker, moet user.permissions in zijn cache contexts verklaren. We greppen op hasPermission in custom modules en templates en auditten de bijbehorende cache metadata.
Twee. Het eerste request na een deploy doet een script, geen mens. Het script raakt de homepage en drie diepe productpagina's aan als anonieme gebruiker via een schone curl, waardoor de caches gewarmd worden met een gegarandeerd correcte render. Redacteuren verifiëren pas daarna.
Drie. Productie-deploys lopen nooit terwijl er admin-sessions actief zijn. Het deploy-script kickt actieve sessions die ouder zijn dan vijf minuten voor het de CDN purget.
Vier. Fastly cachet nooit een response met een session cookie. Dat is de riem die opvangt wat de bretels van de applicatie missen.
De blinde vlek in statische analyse
De meeste statische-analyse-tooling voor Drupal, inclusief het uitstekende phpstan-drupal, vangt ontbrekende cache contexts niet af. De relatie tussen een permissiecheck in Twig en een cache context-lijst in PHP loopt over twee talen en één runtime-grens. Recent werk aan AI-ondersteunde code review begint dit soort cross-file-invariants te bereiken, maar in onze ervaring mist het nog de integratiehoek waarin de bug zich alleen onder een specifieke CDN-configuratie laat zien. Hier was een mens nodig die response headers las.
Waar je morgenochtend begint
Draai je Drupal achter een CDN, dan is dit de goedkoopste audit van vijf minuten die je kunt doen: open je homepage in incognito, daarna in een venster waarin je als admin bent ingelogd, en vergelijk de gerenderde HTML. Diff ze. Zie je admin-only strings, attributen of links binnen fragmenten waarvan de cache denkt dat ze anoniem zijn, dan heb je een kandidaat voor dezelfde bug waar wij negen uur in zijn gaan zitten.
Toen we voor deze klant de homepage-block-stack opnieuw opbouwden, liepen we er steeds tegenaan dat kleine custom blocks cache-aannames opstapelen die niemand documenteert. We zijn uiteindelijk een dunne auditor gaan schrijven die elk block-plugin afloopt en alles markeert dat uit \Drupal::currentUser() leest zonder de bijbehorende cache contexts te verklaren. Dat soort legacy Drupal-werk is tegenwoordig het grootste deel van wat er bij ons binnenkomt.
Kern
Cache contexts zijn een contract dat je met Drupal sluit, en de CDN versterkt elke schending die je vergeet te verklaren.
FAQ
Waarom serveerde de Drupal render-cache een admin-pagina aan anonieme gebruikers?
Het blok dat de admin-Edit-links renderde verklaarde user.permissions niet in zijn cache contexts. Drupal sloeg de admin-render één keer op en speelde hem af voor elke anonieme bezoeker op dezelfde URL.
Wat is een Drupal cache context?
Een cache context is een verklaarde input waar de gerenderde output van afhangt, zoals user.roles, url.query_args of theme. Drupal varieert de cache key op elke context die je opsomt, en vertrouwt erop dat jij ze allemaal opsomt.
Hoe voorkom je CDN cache poisoning in Drupal?
Verklaar elke cache context waar je render van afhangt, verplaats per-user fragmenten naar lazy builders, en configureer de CDN zo dat hij elke response met een session cookie weigert te cachen.
Kan phpstan-drupal ontbrekende cache contexts opsporen?
Vandaag niet. De relatie tussen een Twig-permissiecheck en een PHP getCacheContexts-lijst loopt over twee talen en een runtime-grens, en valt buiten de regels die phpstan-drupal nu meelevert.