021 —Drupal
Drupal sessietokens lekken: drie patronen, drie fixes
Een admin die zonder reden uitgelogd raakt komt zelden door een rare cookie. Op oude Drupal-sites is het meestal een sessietoken uit een referrer-log of een cache.
De eerste hint was een supportticket om 02:18: een adminuser op een veertien jaar oude Drupal 7-site werd steeds uitgelogd, maar de watchdog-tabel vertelde een ander verhaal. Iemand in een ander land opende haar sessie, zat veertig seconden te niksen en sloot dan het tabblad. Haar sessietoken werd ergens gekopieerd, vrijwel zeker uit een referrer-log stroomopwaarts.
Lekkende sessietokens op verouderde Drupal-sites zien er zelden uit als één dramatische breach. Ze zien eruit als gebruikers die klagen over haperende logins. Tegen de tijd dat je het terugherleidt, is de token al gezien door een CDN, een analytics-provider, drie gelogde proxies en de browsergeschiedenis van een developer. Hieronder de drie leakpaden die we het vaakst zien op dit soort stacks, met de fixes die je voor de lunch nog kunt deployen.
1. Sessie-ID's die meeliften in de URL
Deze gaat vooraf aan een hoop moderne hardening en duikt nog steeds op bij Drupal 6- en 7-installaties die jarenlang van server naar server zijn gekopieerd. Als PHP's session.use_trans_sid aan staat, of als session.use_only_cookies uit staat, plakt PHP vrolijk ?PHPSESSID=... achter interne links zodra een bezoeker zonder cookie binnenkomt. Die string staat dan in:
- de adresbalk van de browser
- elke
Referer-header die naar fonts.googleapis.com, het CDN of een analytics-pixel gaat - de access logs van elke proxy ertussen
- de geschiedenissync van de bezoeker naar hun andere apparaten
Om te checken wat jouw stack doet, draai op de server:
php -r 'echo "use_trans_sid=".ini_get("session.use_trans_sid").PHP_EOL;
echo "use_only_cookies=".ini_get("session.use_only_cookies").PHP_EOL;'
Als use_trans_sid iets anders is dan 0, heb je het probleem. Forceer de juiste defaults op Drupal-niveau in sites/default/settings.php, zodat de fix een PHP-upgrade overleeft:
ini_set('session.use_trans_sid', 0);
ini_set('session.use_only_cookies', 1);
ini_set('session.use_strict_mode', 1);
use_strict_mode is de onderschatte. Die vertelt PHP om een sessie-ID te weigeren die het zelf niet heeft uitgegeven, wat de klassieke "plak de cookie in een verse browser"-aanval om zeep helpt. De PHP-handleiding behandelt de volledige lijst session security ini-settings en is van begin tot eind het lezen waard.
Grep daarna je codebase op session_id( en de constante SID in custom modules. We hebben interne "magic links" in clientcode gezien die klanten letterlijk een URL mailen met hun eigen sessie-ID erin. Vervang die door eenmalige tokens via drupal_get_token() of een rij in de flood-tabel.
2. Cookies zonder Secure, HttpOnly of SameSite
Drupal zet een session cookie genaamd SESS<hash> over HTTP en SSESS<hash> over HTTPS. Op een gezonde site wil je die gevlagd zien met Secure, HttpOnly en SameSite=Lax. Op een site die ooit gebouwd is toen de frontend nog http:// was en vijf jaar later via een reverse proxy aan TLS is geknoopt, krijg je vaak geen van de drie.
Bevestig wat je daadwerkelijk uitserveert:
curl -sI https://example.com/user/login | grep -i set-cookie
Bevat de response geen Secure; HttpOnly; SameSite=Lax, fix het dan in settings.php:
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_samesite', 'Lax');
$cookie_domain = '.example.com';
Op Drupal 7 wil je ook $conf['https'] = TRUE; als de site achter een TLS-terminerende proxy zit, anders blijft Drupal de onveilige SESS-cookie uitgeven en accepteert hij die terug over HTTP. De OWASP-uitleg over de HttpOnly-flag beschrijft waarom dit ene attribuut het overgrote deel van XSS-gedreven sessietoken-diefstal blokkeert, en het kost je niets om te deployen.
SameSite is de lastigere. Lax is veilig voor vrijwel elke Drupal-installatie. Strict breekt federated login-flows. None vereist Secure en zet de deur weer open naar cross-site-territorium dat je vrijwel zeker niet wilt. Breekt de callback van een betaalprovider nadat je Lax hebt gezet, dan is je echte bug dat die callback een GET is, niet je cookie-policy.
3. Pagina's met ingelogde gebruikers uit de page cache
Deze is het meest gênant, want het lek is meestal zelf veroorzaakt. Drupal's interne page cache, plus een Varnish-laag ervoor, plus een CDN aan de edge: prima voor anonieme traffic. Het moment dat één van die lagen een response cachet met een CSRF-token, form build ID of username van een ingelogde gebruiker in de markup, krijgt elke volgende bezoeker datzelfde token mee.
Een paar signalen dat je dit probleem hebt:
- een
Cache-Control: public, max-age=...header op een pagina met een<form>en een verborgenform_token Set-Cookie: SESS...dat verschijnt op dezelfde response alsX-Drupal-Cache: HIT- twee anonieme gebruikers die exact dezelfde
form_build_idkrijgen als je vanuit incognito reload
De fix is om de cachebaarheid van authenticated responses expliciet te maken. In .htaccess, of in je Varnish VCL, nooit cachen als er een session cookie aanwezig is:
# .htaccess, vóór het Drupal-rewriteblok
SetEnvIf Cookie "S?SESS[a-f0-9]+=" has_session
Header always set Cache-Control "private, no-store, no-cache, must-revalidate" env=has_session
Header always unset Set-Cookie env=has_session
Audit op Drupal 7 alles wat drupal_page_is_cacheable() aanroept. Op Drupal 8 en hoger doet het cache contexts en tags-systeem dit werk voor je, maar alleen als je custom blocks de juiste contexts declareren. Een block dat user-specifieke content rendert zonder cache.contexts: ['user'] wordt mee gebakken in de anonieme variant van de pagina en uitgeserveerd aan de hele wereld.
Nu je toch onder de motorkap zit: draai dit tegen de database om te zien hoeveel actieve sessies je per uid hebt. Meer dan een handvol voor dezelfde gebruiker, op verschillende IP-prefixen, is een duidelijk signaal:
SELECT uid,
COUNT(*) AS sessions,
COUNT(DISTINCT SUBSTRING_INDEX(hostname, '.', 2)) AS networks
FROM sessions
WHERE uid > 0
GROUP BY uid
HAVING networks > 2
ORDER BY networks DESC;
Is dezelfde uid ingelogd vanaf vijf verschillende /16-netwerken, dan wordt de cookie gedeeld, niet het wachtwoord.
Een opmerking over tooling
Bij het bouwen van Pier liepen we tegen exact dit patroon aan op de Drupal 7-installatie van een klant, en wat we uiteindelijk deden was een kleine audit direct in de chat hangen: een operator vraagt de MySQL editor om sessies te tonen met meer dan twee verschillende netwerken, patcht daarna settings.php en pusht het terug via SFTP zonder de app te verlaten. De version history zit één klik verderop, wat handig is de eerste keer dat je SameSite=Lax in productie zet en een nichflow breekt.
Open sites/default/settings.php, plak de drie ini_set-regels uit sectie twee, deploy en draai opnieuw curl -sI tegen je loginpagina. Staat er nu Secure; HttpOnly; SameSite=Lax in de Set-Cookie-response, dan heb je vóór de lunch het grootste van de drie lekken dichtgezet. De andere twee kunnen wachten tot de volgende sprint.
— Vragen —
Breekt SameSite=Lax de callbacks van betaalproviders?
Alleen als de callback een GET is die de session cookie moet meedragen. POST-callbacks werken prima onder Lax. Maak van de GET-callback een POST, of geef de provider een eenmalig token in plaats van te leunen op de sessie.
Is session.use_strict_mode veilig om aan te zetten op een productie-Drupal-site?
Ja, voor vrijwel elke installatie. Hij weigert alleen sessie-ID's die PHP niet zelf heeft uitgegeven. Het enige dat het breekt is custom code die een client een sessie-ID overhandigt om later mee in te loggen, en dat moet je sowieso niet doen.
Waarom geeft Drupal nog steeds een onveilige cookie uit nadat ik HTTPS heb aangezet?
Drupal 7 schakelt pas over op de SSESS-cookie als hij ziet dat de request HTTPS is. Achter een TLS-terminerende proxy heb je daarnaast $conf['https'] = TRUE; nodig in settings.php, anders valt hij terug op SESS.