120 —PHP
Cache-Control-lek: het woord dat PHP-sessies liet lekken
Om 16:47 zag een klant andermans bestelhistorie in zijn eigen dashboard. De bug: een Cache-Control-header op de PHP-accountpagina, één woord te kort.
Op een donderdag om 16:47 stuurde de oprichter van een Nederlands bureau waar we mee samenwerken een screenshot door, afkomstig van een woedende klant: in het accountdashboard van die klant stond de naam van iemand anders, de bestelhistorie van iemand anders, het opgeslagen adres van iemand anders. Hij had de pagina ververst. Nog steeds het verkeerde account. Hij had een privé-venster geopend en opnieuw ingelogd. Weer het verkeerde account. De helpdesk van het bureau had in de twintig minuten ervoor nog vier vergelijkbare tickets binnengekregen.
Toen we erbij kwamen op de call had de support-engineer de voor de hand liggende hendel al overgehaald: de Cloudflare-cache leeggegooid. Binnen negentig seconden stopten de tickets. Het lek had ongeveer anderhalf uur gelopen op een custom-PHP-shop die zo'n 2.000 ingelogde sessies per dag verwerkt. De oorzaak bleek om 18:10 één ontbrekend woord in een Cache-Control-header op de accountpagina.
Wat de edge eigenlijk zag
Het accountdashboard werd geserveerd door een handler die er ongeveer zo uitzag:
// /account/index.php
session_start();
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: max-age=300');
render_account($_SESSION['user_id']);
Twee jaar geleden had een junior dev de regel met max-age=300 toegevoegd om druk van de database te halen na een Black Friday-piek. Het werkte, in die zin dat opeenvolgende loads van het dashboard voor dezelfde gebruiker binnen vijf minuten PHP volledig oversloegen. De pagina was snel. Niemand klaagde. De regel bleef staan.
Wat die regel niet zei, was private. Volgens de Cache-Control-referentie van MDN is een response zonder private of no-store vrij spel voor elke shared cache tussen origin en browser. Cloudflare, dat tussen PHP en de gebruiker zit, is zo'n shared cache. Het las max-age=300, zag geen tegengestelde directive en behandelde de gerenderde HTML als een statisch bestand dat aan de volgende bezoeker van dezelfde URL geserveerd mocht worden.
De volgende bezoeker kreeg de naam van de vorige bezoeker. En in sommige response-paden, waar de session-rotation-middleware van het framework cookies reset, kreeg de volgende bezoeker ook de PHPSESSID van de vorige bezoeker mee in een gecachte Set-Cookie-header. Dat is het deel waar de oprichter die nacht niet rustig van sliep.
De daadwerkelijke fix, in drie regels
De patch was bewust klein. We veranderden één header, voegden een tweede toe en gingen live:
header('Cache-Control: private, no-store, max-age=0');
header('Pragma: no-cache');
header_remove('Expires');
Voor de eigen shared edge had alleen private het lek al gestopt. We legden no-store erbovenop als dubbele zekerheid, omdat de shop ook een Varnish-laag draait voor statische pagina's en we onder tijdsdruk niet wilden gaan discussiëren over hoe Varnish elke directive interpreteert. De Pragma-regel is voor oudere tussenliggende proxy's waar sommige zakelijke klanten van de shop nog achter zitten. Nutteloos voor Cloudflare, ongevaarlijk overal elders.
Daarna liepen we de rest van de codebase langs op zoek naar dezelfde fout. We vonden nog vier handlers met max-age-regels die private hadden moeten zijn: de pagina met besteldetails, het endpoint voor opgeslagen adressen, de wishlist-API en een van de tussenstappen in de checkout. Geen ervan had volgens de access logs al gelekt, maar ze waren één Cloudflare-page-rule-wijziging verwijderd van het wél doen.
Het audit-patroon dat dit vangt
Zodra je er één hebt gezien, zie je ze overal. Het patroon om op te grep'en in elk PHP-project ouder dan drie jaar:
grep -rEn "Cache-Control:.*max-age" --include="*.php" .
grep -rEn "header\(.*Cache-Control" --include="*.php" .
Voor elke hit is de vraag: levert deze URL ooit content op die afhankelijk is van de sessie? Zo ja, dan moet de directive private of no-store bevatten. De OWASP-richtlijn over session management is daar onverbloemd over: elke response die session-afhankelijke content bevat, moet zichzelf als private voor shared caches declareren.
Dezelfde audit moet ook .htaccess dekken, waar deze headers soms globaal worden gezet:
# Fout: geldt ook voor /account, tenzij expliciet uitgesloten
<FilesMatch "\.(php|html)$">
Header set Cache-Control "max-age=600"
</FilesMatch>
# Beter: alleen de dingen die je echt gecachet wilt hebben
<FilesMatch "\.(css|js|woff2|png|jpg|svg)$">
Header set Cache-Control "public, max-age=2592000, immutable"
</FilesMatch>
Een algemene Header set in .htaccess is de grotere versie van dezelfde bug. Hij raakt elke dynamische PHP-response onder dat prefix, inclusief de responses die een Set-Cookie-header meesturen. We hebben dit de afgelopen zes maanden alleen al gezien in twee Magento 1-codebases en één bejaarde Drupal 7-site.
Waarom de bug zo lang verborgen blijft
De reden dat deze klasse bugs maandenlang loopt voordat ze zich manifesteert, is dat de voorwaarden voor de trigger smal zijn. De shared cache serveert pas een verouderde response wanneer:
- Twee gebruikers dezelfde URL opvragen binnen het
max-age-venster. - De eerste response daadwerkelijk in de edge cache is beland (wat afhangt van de response-status, de grootte en de eigen eligibility-regels van de CDN).
- De request key van de tweede gebruiker matcht: zelfde path, zelfde vary headers, zelfde edge node.
Voor de meeste account-pagina-URL's (/account, /dashboard) is de URL identiek voor elke ingelogde gebruiker. Zodra aan de eligibility is voldaan, ligt het lek dus één cache hit verderop. De trigger bij het Nederlandse bureau was een Cloudflare-configuratiewijziging eerder die middag: een developer had 'Cache Everything' aangezet op een page rule die door een typo in het path-patroon ook /account/* raakte. De foute header lag daar al twee jaar te wachten op die page rule.
De les is niet 'audit je Cache-Control-headers wanneer je CDN-configuratie verandert'. De les is dat de Cache-Control-header op een response met sessie defensief fout-proof moet zijn, want de CDN-configuratie erboven verandert in de levensduur van de site, en jij staat niet in de kamer als dat gebeurt.
Het kleine ding dat je vandaag kunt doen
Pak de oudste account-pagina-handler in je codebase. Draai de grep hierboven. Lees de headers die je vindt met de vraag: 'wat gebeurt er als Cloudflare of Varnish of een corporate proxy besluit dit als cacheable te behandelen?' Als het antwoord is 'de volgende gebruiker ziet de vorige gebruiker', verander dan die regel.
Toen we Pier bouwden, liepen we tegen precies dit soort probleem aan op de legacy site van een klant: we hebben het uiteindelijk zo opgelost dat elke header-wijziging in de versiegeschiedenis terechtkomt, zodat je op het moment dat een lek begint de response-headers van vandaag kunt diffen tegen die van vorige woensdag, en het ene woord ziet dat is verschoven. Diezelfde versionering loopt over de MySQL-editor, wat de volgende ochtend uitmaakte toen we moesten bevestigen dat er geen sessies daadwerkelijk gekaapt waren.
Wil je één concrete vervolgstap voordat je dit tabblad sluit: open je account-pagina-handler, zoek de Cache-Control-regel op en bevestig dat het woord private erin staat. Dat is de kleinste versie van de audit, en hij vangt de versie van deze bug die het meeste pijn doet.
— Vragen —
Is Cache-Control: private genoeg om een CDN te beletten session-afhankelijke pagina's te cachen?
Voor compliant shared caches wel, maar leg er no-store overheen. Sommige tussenliggende proxy's interpreteren directives soepel, en Cloudflare-page-rules kunnen origin-headers in bepaalde plantiers volledig overschrijven.
Wat is het verschil tussen no-cache en no-store op een PHP-response?
no-cache staat opslag toe maar vereist revalidatie bij elke request. no-store verbiedt opslag in elke cache, waar dan ook. Voor pagina's die afhangen van session state is no-store de veiligere keuze.
Kun je ingelogde pagina's überhaupt cachen zonder sessies te lekken?
Ja, met per-user cache keys via Vary op een session-identificerende header, of met edge-side includes voor de dynamische fragmenten. De meeste teams vinden dat de operationele kosten zwaarder wegen dan de databasewinst.