093 —Security
.htaccess-headerblok: security cheatsheet voor de oplevering
Een kant-en-klaar .htaccess-blok met security headers, file locks en PHP-in-uploads-blokkades. Eén keer plakken, beter slapen, sleutels overdragen zonder knoop in je maag.
Vrijdagmiddag. Het migratieticket is dicht, de klant heeft staging goedgekeurd, en er staat een conceptmail open met als titel Oplevering: inloggegevens bijgevoegd. Voor die verzendknop ingedrukt wordt, is er een blokje .htaccess dat we altijd bovenaan de root-config van de productiesite plakken. Het kost negentig seconden, het levert tien jaar verzamelde "oh nee, die ene"-fixes mee, en het laat het volgende pentestrapport ongeveer half zo onaardig lezen.
Dit artikel ís dat blok. Het geheel, met de redenering achter elke regel, in de volgorde waarin we het plakken. Het is gericht op Apache 2.4 op shared hosting, waar de meeste verouderde site-installaties van WordPress, Drupal en Magento nog altijd draaien.
Het blok
Plak dit bovenaan de .htaccess in de docroot, boven eventuele bestaande rewrite-regels van WordPress, Drupal of Magento.
# === Handover hardening block, v6 (2026-06)
# Tested on Apache 2.4 + cPanel / Plesk / DirectAdmin
<IfModule mod_headers.c>
Header always set X-Frame-Options "SAMEORIGIN"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()"
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always unset X-Powered-By
Header always unset Server
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
# WordPress author enumeration
RewriteCond %{QUERY_STRING} ^author=\d+ [NC]
RewriteRule .* - [F,L]
# xmlrpc brute force amplifier
RewriteRule ^xmlrpc\.php$ - [F,L]
</IfModule>
# Lock dotfiles and stack config files
<FilesMatch "^(\.env|\.git.*|wp-config\.php|configuration\.php|settings\.php|local\.xml|app\.php)$">
Require all denied
</FilesMatch>
# No PHP execution in user uploads
<Directory "/home/CLIENT/public_html/wp-content/uploads">
<FilesMatch "\.(php|phar|phtml|pl|py|cgi)$">
Require all denied
</FilesMatch>
</Directory>
Options -Indexes
ServerSignature Off
Dat is alles. Zeven response headers, twee rewrite-regels, drie afsluitingen. De rest van dit artikel is de redenering, regel voor regel, zodat je het tijdens code review kunt verdedigen en kunt schrappen wat niet van toepassing is.
Waarom elke header zijn regel verdient
De vijf die we zetten
X-Frame-Options: SAMEORIGIN. Ja, het is de oudere broer van CSP's frame-ancestors-directive, en ja, een fatsoenlijke CSP maakt hem overbodig. Op een legacy site zitten we doorgaans nog een jaar verwijderd van een echte CSP, en het kwaad om beide mee te leveren is er niet. Het blokkeert de goedkoopste clickjacking-truc: de admin login in een onzichtbare iframe laden.
X-Content-Type-Options: nosniff. Zonder dit gaan oudere browsers MIME-types raden. Een .jpg in /uploads met PHP-bytes erin kan dan uitgevoerd worden. We hebben dit gezien op een Magento 1-shop met een contactformulier-upload die niet valideerde. De one-liner is nosniff. MDN heeft de volledige referentie.
Referrer-Policy: strict-origin-when-cross-origin. Dezelfde default die Chrome verstuurt. Voorkomt dat je /wp-admin/-URLs uitlekken in third-party analytics zodra de redacteur in een conceptpost op een uitgaande link klikt.
Permissions-Policy. De meeste legacy sites hebben niets te zoeken bij camera of geolocation. Die poortjes sluiten we standaard. Het token interest-cohort=() meldt de site af voor FLoC en zijn opvolgers, wat op dit moment vooral hygiëne is.
Strict-Transport-Security. 31536000 is één jaar. We zetten geen preload bij de oplevering, want zodra een domein op de browser preload list staat, kost het er afkrijgen maanden, en we kunnen niet aannemen dat de klant certificaten eeuwig gezond houdt.
De twee die we uitschakelen
Apache en PHP verklappen standaard hun versies via Server en X-Powered-By. Er is geen goede reden waarom het open internet moet weten dat je stack PHP 7.4.33 op Apache/2.4.41 draait. mod_headers unset snoeit beide weg. ServerSignature Off doet de rest, inclusief de voettekst die Apache aan errorpagina's plakt.
De afsluithelft: bestanden, paden, PHP in uploads
Author enumeration. WordPress antwoordt op /?author=1 met een 301 naar /author/admin/ of welke slug dan ook. Dat is een gratis gebruikersnaam voor iedereen die wpscan draait. Blokkeer de query string en de redirect gaat nooit af.
xmlrpc.php. Tenzij de klant specifiek Jetpack of de WordPress mobile app gebruikt, is xmlrpc een brute-force amplifier: één POST kan via system.multicall duizend wachtwoorden proberen. We weigeren hem botweg. Als Jetpack later piept, whitelist dan de gepubliceerde IP-ranges van Automattic in plaats van de deur weer open te zetten.
FilesMatch op config- en dotfiles. Dekt wp-config.php (WordPress), settings.php (Drupal), configuration.php (Joomla), local.xml (Magento 1), en eventuele zwerf-.env of .git die het vorige bureau heeft achtergelaten. We hebben meer dan eens een gevulde .env aangetroffen die nog werd geserveerd vanuit een Laravel-restdirectory in productie.
PHP in uploads. Dit is degene die echte sites heeft gered. /wp-content/uploads hoort nooit PHP uit te voeren. Hetzelfde geldt voor Drupal's /sites/default/files en Magento's /pub/media. Het Directory-blok pint de regel vast op de upload-directory; pas het absolute pad aan per site. Het OWASP Secure Headers project heeft meer, mocht je verder willen gaan.
Voetangels voor je commit
Nog een paar die ons bijten:
- Test altijd eerst op staging. Als
mod_headersniet geladen is, dropt Apache stilzwijgend alles binnen hetIfModule-blok. De site werkt door; de headers niet. Curl met-I https://example.comen verifieer dat elke regel daadwerkelijk aanwezig is. - Het
Directory-blok heeft een absoluut serverpad nodig, geen URL.CLIENTin het fragment is een placeholder. Op cPanel-hosts is dat doorgaans/home/USERNAME/public_html; op Plesk/var/www/vhosts/example.com/httpdocs. - Staat de site achter Cloudflare of een andere CDN, dan kunnen headers ook aan de edge worden gezet. Twee kopieën van de meeste headers zijn onschuldig. Twee HSTS-headers met conflicterende max-ages leiden tot ongedefinieerd browsergedrag. Kies één origin en hou je eraan.
- Nginx is een andere taal. Vertaal de directives naar
add_headerenlocation-blokken. De semantiek is gelijk; de syntax niet. Apache's mod_headers-documentatie is de canonieke referentie.
Wat je vandaag kunt doen
Open de .htaccess op de volgende legacy site waar je aan zit. Diff ons blok tegen wat er al staat. Je zult de helft hiervan op drie verschillende plekken vinden, door drie verschillende plugins gezet, soms elkaar tegensprekend. Consolideer. Geef commentaar op wat blijft. Geef commentaar op waarom.
Toen we Pier bouwden liepen we hier op vrijwel elke site die we aankoppelden tegenaan: niemand in het team wilde degene zijn die een HSTS-header op de verkeerde host had uitgerold. Wat we uiteindelijk hebben gedaan, is van elke .htaccess-aanpassing een checkpoint maken in de version history, met de aangekoppelde MySQL editor bij de hand om de WordPress siteurl terug te zetten als iemand zichzelf met een header-wijziging uit de admin had gesloten. Twee klikken om terug te draaien, geen SSH-sessie.
Het kleinste wat je vandaag kunt doen: plak het blok hierboven als commentaar bovenaan jullie snippet-repo, label het v1, en stop met dit probleem bij elke oplevering opnieuw van nul op te lossen.
— Vragen —
Heb ik X-Frame-Options nog nodig als ik een Content-Security-Policy uitrol?
Nee, CSP's frame-ancestors vervangt hem. Maar op een legacy site zonder echte CSP is X-Frame-Options de enkele regel die je clickjacking-bescherming oplevert. Goedkoop om beide te houden.
Waarom geen HSTS preload aanzetten bij de oplevering?
Omdat zodra een domein op de browser preload list staat, het er afhalen maanden duurt en een Chrome-release vereist. Bij een oplevering kun je niet garanderen dat de klant elk subdomeincertificaat eeuwig vernieuwd houdt.
Breekt het weigeren van xmlrpc.php Jetpack of de WordPress mobile app?
Ja. Gebruikt de klant een van beide, whitelist dan Automattic's gepubliceerde IP-ranges in plaats van de deny-regel te verwijderen. De meeste bureausites hebben geen actieve xmlrpc-afnemer en de deny is veilig.