081 —Drupal
Drupal 7 achter Cloudflare: admin en image styles intact
Een Drupal 7-site achter Cloudflare zetten en twee dingen breken meteen: editor login loopt vast, image styles geven 404. Een playbook die wel houdt.
De flip om 22:55
De oranje cloud ging om 22:55 aan. Tegen 23:41 zat de lead developer van een klein Nederlands bureau waar we mee werken in onze DM's met drie screenshots: een /user login die meteen terugstuurde naar /user, een editor-homepage carrousel waar elke image style een 404 gaf, en een Slack-bericht van haar klant met de vraag of de demo de volgende ochtend om 09:00 nog doorging.
De legacy site was een twaalf jaar oude Drupal 7-installatie. Ongeveer 4.200 nodes, een editorial layout met veel Views, een hele berg image styles voor de carrousel en article cards, en een adminteam van negen. Het migratiedoel was bescheiden: Cloudflare ervoor zetten voor DDoS-bescherming en bandbreedte, vóór een perscyclus. Niemand was van plan om code aan te raken. Twintig minuten na het flippen van de DNS-proxy waren twee specifieke dingen kapot, en het zijn bijna altijd dezelfde twee dingen op elke Drupal 7-site die voor het eerst achter een CDN wordt gezet.
Dit is de playbook die we die avond met haar doorlopen hebben, opgeschreven zodat je hem kunt doen in het rustige deel van een middag in plaats van 's nachts.
Settings.php: vertrouw eerst de proxy
Drupal 7 ziet de Cloudflare-edge als de client tenzij je iets anders configureert. Dat breekt meer dan alleen IP-logging. Alles wat ip_address() aanroept, waaronder Flood, login throttling en de meeste spammodules, gaat elke bezoeker behandelen als dezelfde machine. De eerste aanpassing gaat in sites/default/settings.php:
// Trust Cloudflare's edge as the reverse proxy.
$conf['reverse_proxy'] = TRUE;
$conf['reverse_proxy_header'] = 'HTTP_CF_CONNECTING_IP';
$conf['reverse_proxy_addresses'] = array(
// IPv4 ranges, current as of writing.
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
);De lijst komt rechtstreeks van cloudflare.com/ips. Haal hem daar op tijdens deploy in plaats van hem één keer te copy-pasten en dan te vergeten; de ranges veranderen wel degelijk. Wij hebben een Drush-command in onze deploy hook zitten die deze array overschrijft vanuit de officiële endpoint.
HTTP_CF_CONNECTING_IP is bij Cloudflare de schonere header om te lezen; HTTP_X_FORWARDED_FOR werkt ook, maar dan moet je de chain parsen. Door CF-Connecting-IP te lezen geeft ip_address() elke keer één waarde terug.
HTTPS en de redirect loop
De loginloop die het bureau om 23:41 raakte heeft bijna altijd dezelfde root cause: Cloudflare termineert TLS op de edge en proxiet HTTP naar de origin. Drupal ziet HTTP, beslist dat de huidige request onveilig is, en op elk pad dat HTTPS vereist (wat met de Secure Login-module of een custom login redirect ook /user omvat) stuurt het een 302 naar https://.... Cloudflare ontvangt die, serveert hem, de browser volgt hem, Cloudflare proxiet weer HTTP naar de origin. Loop.
Twee extra regels in settings.php:
// Detect HTTPS via Cloudflare's forwarded protocol.
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])
&& $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
// Pin the canonical base URL.
$base_url = 'https://www.example.com';De X-Forwarded-Proto-header is de standaard manier om het oorspronkelijke schema door een reverse proxy heen te zien. Door $_SERVER['HTTPS'] vroeg te zetten geeft drupal_is_https() de waarheid terug, en eindigt de redirect chain.
Pin $base_url expliciet. Anders leidt Drupal hem af uit $_SERVER['HTTP_HOST'], wat Cloudflare correct zet, maar wat een gedrochtelijke Worker, een Page Rule die Host herschrijft, of een slecht geconfigureerde Argo Tunnel stilletjes kan verminken. Door hem te pinnen elimineer je een hele klasse bugs uit je toekomst.
Cache rules die editors ingelogd houden
Cloudflare cachet standaard statische assets en respecteert Cache-Control op dynamische responses. Drupal stuurt Cache-Control: no-cache, must-revalidate, post-check=0, pre-check=0 op anonieme cacheable pagina's en een strenger private op authenticated pagina's, wat prima is, maar je moet niet alleen op de origin headers vertrouwen. Je wilt dat de edge weet dat alles met een Drupal session cookie niet cacheable is.
Drupal session cookies hebben prefix SESS over HTTP en SSESS over HTTPS. De cache rule die je wilt, in de Cloudflare Cache Rules UI:
When incoming requests match:
(http.cookie contains "SESS") or (http.cookie contains "SSESS")
Then:
Cache eligibility: Bypass cacheVoeg een tweede rule toe voor de paden die nooit gecached mogen worden, zelfs niet voor een anonieme bezoeker die koud binnenkomt:
When incoming requests match:
(starts_with(http.request.uri.path, "/user"))
or (starts_with(http.request.uri.path, "/admin"))
or (http.request.uri.path contains "/edit")
or (starts_with(http.request.uri.path, "/node/add"))
Then:
Cache eligibility: Bypass cacheDe /edit contains-match vangt /node/123/edit, /taxonomy/term/4/edit en wat custom modules ook maar blootstellen. Het is een botte regel, maar op een Drupal admin-oppervlak is bot correct.
Image styles en het itok token
Het tweede kapotte ding op de screenshot van 23:41, de 404-carrousel, heeft een andere oorzaak en een andere fix. Image styles worden on demand gegenereerd. De URL ziet eruit als:
/sites/default/files/styles/carousel_large/public/hero.jpg?itok=Hx3p_kQ8De itok query parameter is een HMAC van het derivative path en de hash salt van je site. De functie image_style_url() genereert hem; image_style_deliver() valideert hem voordat het derivative wordt gegenereerd. Het token bestaat zodat iemand jouw server niet kan vragen een miljoen derivatives te genereren door URL's te raden.
Twee dingen gaan fout achter Cloudflare. Ten eerste: Cloudflare's standaard Cache Level is Standard, wat de query string in de cache key meeneemt. Dat deel is prima. Maar sommige bureaus, ook wij ooit, hebben Cache Level op een eerder project op Ignore Query String gezet en die instelling geërfd op een nieuwe zone. Wordt de query string genegeerd, dan valt elke image style URL terug op dezelfde cache key, wint de eerste request die binnenkomt, en krijgt elke volgende request met een andere itok een gecachte 403.
Check dit in de Cache Rules. De instelling die je wilt is:
Cache key includes:
- Query string: All query string parameters
- Host: example.comTen tweede: het derivative-bestand staat nog niet op disk wanneer de URL voor het eerst wordt opgevraagd. De .htaccess van Drupal herschrijft de request naar index.php, die image_style_deliver() aanroept, het bestand genereert, naar sites/default/files/styles/ schrijft en met een 200 terugstuurt. De volgende request leest het bestand direct van disk via Apache. Het probleem is dat die allereerste generation response geen Cache-Control: public meedraagt, en op sommige configuraties draagt hij Cache-Control: no-cache. Cloudflare cachet hem dan niet. Erger nog: geeft Apache een 500 omdat het filesystem niet schrijfbaar is, of een 403 omdat de itok niet klopt (een salt mismatch tussen omgevingen veroorzaakt dat), dan cachet Cloudflare de error voor de default TTL.
Twee fixes. In settings.php, zorg er absoluut voor dat de hash salt identiek is in elke omgeving die een image style URL deelt:
$drupal_hash_salt = file_get_contents('/var/www/.drupal-salt');En in de Cache Rules, zet:
When incoming requests match:
starts_with(http.request.uri.path, "/sites/default/files/styles/")
Then:
Cache eligibility: Eligible for cache
Edge TTL: Override origin, 1 month
Browser TTL: 1 day
Cache by status code: 200-299 cache, 400-499 bypass, 500-599 bypassDe status-code rule is de regel die het bureau die avond had gered. De eerste 403 van een salt mismatch mag niet aan de edge blijven plakken. Zodra je de salt fixt, bouwt de volgende request hem correct opnieuw op.
Pre-warm waar je kunt
Heeft de site meer dan een paar honderd derivatives, draai dan een Drush-task in deploy die over de image fields loopt en elke style URL één keer server-side opvraagt, voordat de eerste echte bezoeker binnenkomt. drush eval met een loop over field_get_items() en image_style_url() is een script van vijftig regels en bespaart je de cold-start tail.
Verifieer de chain voor je weggaat
Voor je de laptop dichtklapt, draai vier checks. Vanaf een machine die niet achter je kantoor-proxy zit:
curl -I https://www.example.com/
# expect: cf-cache-status: HIT (after the second call)
curl -I https://www.example.com/user/login
# expect: cf-cache-status: BYPASS
curl -I --cookie "SESS123=fake" https://www.example.com/
# expect: cf-cache-status: BYPASS
curl -I https://www.example.com/sites/default/files/styles/carousel_large/public/hero.jpg?itok=Hx3p_kQ8
# expect: HTTP/2 200 and, on the second call, cf-cache-status: HITLog daarna in als een echte editor in een incognito-venster. Sla een node op. Upload een afbeelding. Kijk of de styles renderen. De hele loop kost vier minuten en is het verschil tussen slapen, en om 06:00 worden gewekt door de CEO van de klant.
Wat we ervan geleerd hebben
Drupal 7 is twaalf jaar voorbij zijn eerste release en een jaar voorbij officieel end-of-life, en de sites die er nog op draaien doen dat bijna altijd omdat ze herschrijven een zescijferig project is waar niemand voor heeft getekend. Een CDN ervoor zetten is een van de moves met de meeste leverage om de runway te verlengen: het absorbeert verkeerspieken, verbergt het IP van de origin, en geeft je een plek om een WAF af te dwingen zonder code aan te raken. De kosten zijn die halve dag knutselen hierboven, één keer gedaan.
Toen we Pier bouwden liepen we hier exact tegenaan op een Drupal 7-installatie van een klant die we aan het auditen waren. Hoe we het hebben opgelost: één-klik settings.php-snapshots in de version history zodat het terugdraaien van een verprutste reverse-proxy-wijziging één toetsaanslag is, plus een MySQL editor die uitgaat van de aanname dat je de sessions- en cache_form-tabellen aan het inspecteren bent terwijl je een auth loop aan het debuggen bent.
Het kleinste wat je vandaag kunt doen: open je productie-settings.php en grep hem op reverse_proxy. Staat die array er niet in, dan heb je een blootgesteld origin-IP en een gebroken ip_address() die wachten om toe te slaan. Fix die ene regel vanavond; de rest kan wachten tot dinsdag.
— Vragen —
Krijgt Drupal 7 nog security support?
Community support is in januari 2025 gestopt. Het D7 Security Team bestaat niet meer. Je staat er alleen voor met patches, en precies daarom telt een WAF ervoor zetten nu zwaarder dan vroeger.
Heb ik Cloudflare's Full (Strict) SSL nodig met Drupal 7?
Ja, als je het kunt regelen. Installeer een gratis origin certificate van Cloudflare, configureer Apache om het te vereisen, en zet de encryption mode op Full (Strict). Flexible mode is de redirect-loop val.
Moet ik Rocket Loader uitzetten?
Op de meeste Drupal 7-sites: ja. Het stelt inline scripts uit op een manier die de admin Overlay, CKEditor en sommige Views AJAX breekt. Laat Auto Minify aan voor CSS, uit voor JS, totdat je elke editorial workflow hebt getest.