010 —Security
Web root: zeven bestanden die je nooit publiek serveert
Een WordPress-site die sinds 2014 draait, vertelt alles over zichzelf als je weet welke zeven bestanden je moet opvragen. Geen ervan hoort er te staan.
Een klein Amsterdams bureau stuurde vorige maand om 23:41 een Loom op. De WordPress-installatie van hun klant, sinds 2014 in de lucht, had net een keurig geformuleerde sommatie van Cloudflare ontvangen. Het forensisch rapport wees de site zelf aan als bron. Iemand had wp-config.php.bak uit de document root meegenomen, de salts ontsleuteld en de database stilletjes gebruikt voor een credential-stuffing setup. Het bureau had de site achttien maanden eerder overgenomen. Het backup-bestand stond er sinds de laatste edit van de oorspronkelijke developer in 2018.
Niets aan dat incident is bijzonder. Elke legacy site die wij auditen, heeft minstens één van de zeven bestandstypes hieronder gewoon zichtbaar in de publieke web root staan. Voor geen ervan heb je een kwetsbaarheid nodig om ze te lezen. Het zijn simpele HTTP GET-requests.
De audit van zeven minuten
De audit is mechanisch. Vanaf een laptop, met de hostname van de site in $DOMAIN, draai je een kort setje curl -I requests en lees je de statuscode af. Alles wat 200 teruggeeft is een vinding. 403 of 404 betekent dat het bestand er niet staat of al op serverniveau geblokkeerd is. Voor deze pass heb je geen interne toegang nodig. Dat is precies het punt: een aanvaller ook niet.
DOMAIN="example.com"
for path in .env .git/config wp-config.php.bak backup.sql phpinfo.php \
composer.lock wp-content/debug.log; do
printf "%-30s " "$path"
curl -s -o /dev/null -w "%{http_code}\n" "https://$DOMAIN/$path"
doneDie loop van zeven regels is de hele audit. De rest van deze post gaat over wat elke 200 betekent en hoe je er voor het einde van de dag een 404 van maakt.
.env en de dotenv-familie
Moderne Laravel-, Symfony- en Bedrock-stijl WordPress-installaties leveren een .env-bestand in de project root voor database-credentials, app keys en API-tokens van derden. Op zich een prima conventie. Het ongeluk gebeurt als de project root tegelijk de document root is, wat op cPanel en shared hosting standaard zo is. Een request naar https://example.com/.env geeft het bestand dan letterlijk terug, omdat Apache out-of-the-box buiten .htaccess en .htpasswd geen dotfiles blokkeert.
De fix is twee regels in je virtual host of top-level .htaccess:
<FilesMatch "^\.env">
Require all denied
</FilesMatch>Voor Nginx, het equivalent binnen het server-block:
location ~ /\.env { deny all; return 404; }Verplaats het bestand vervolgens helemaal uit de document root als je stack dat toelaat. De meeste moderne PHP-frameworks ondersteunen een expliciet env-pad in hun bootstrap.
De .git-directory
Een blootgestelde .git/-folder is de ergste vinding op deze lijst, omdat het bijna altijd de hele source tree oplevert: credentials, migratiebestanden en elk secret dat ooit gecommit en later "verwijderd" is. Tools als git-dumper reconstrueren de repo binnen een minuut uit .git/config, .git/HEAD en de pack files.
Test het:
curl -s "https://example.com/.git/config" | head -n 3Zie je [core], dan is de hele history te downloaden. De remediatie volgt hetzelfde patroon als bij .env:
RedirectMatch 404 /\.gitDe betere fix is om nooit via git pull op productie te deployen. Bouw het artefact ergens anders en rsync alleen de bestanden die je daadwerkelijk wilt serveren. De .htaccess-gids van het Apache-project is hier het herlezen waard voor de precedence-regels.
SQL-dumps in /backup/ en /old/
De senior developer uit 2018 die de oude site hernoemde naar /old/ en een backup.sql in /backup/ dumpte vóór de migratie, is statistisch de grootste enkele bron van credential-leaks die we in audits tegenkomen. De dumps bevatten gehashte admin-wachtwoorden (op echt oude installs vaak unsalted MD5), e-mailadressen en klantgegevens.
De lijst paden om te controleren is kort en bijna altijd hetzelfde:
/backup.sql,/dump.sql,/database.sql/backup/,/backups/,/old/,/tmp//site_2018.tar.gz,/wp_backup.zip
Verwijder ze. Er is geen variant waarin zo'n bestand het verdient om in de publieke web root te blijven staan.
Scratch files van editors en het OS
Drie smaken, allemaal te voorkomen. Vim laat .swp- en .swo-bestanden achter als een editor crasht. Veel editors en FTP-clients schrijven bij opslaan filename~ of filename.bak. macOS strooit .DS_Store in elke directory die het aanraakt. Dat laatste wordt het meest onderschat: het bevat de volledige directory listing als binary blob, en tools als ds_store_exp zetten dat binnen seconden weer om in een pad-overzicht.
Eén block dekt alle drie:
<FilesMatch "(\.(bak|old|orig|swp|swo)|~|\.DS_Store|Thumbs\.db)$">
Require all denied
</FilesMatch>Dezelfde regex vertaald voor Nginx werkt net zo goed. De kosten zijn één config-block. De besparing is elk "ik heb wp-config even snel via FTP aangepast"-incident dat erop volgt.
Diagnostische scripts
Het phpinfo.php-bestand dat de vorige developer in 2019 achterliet om "even te checken of mod_rewrite geladen was", staat er nog steeds. Het geeft de volledige output van phpinfo() terug, inclusief de document root, het pad naar de PHP-binary, elke geladen extensie, elke environment variable en regelmatig een database-wachtwoord als dat via SetEnv is gezet. Varianten die we in audits zien: info.php, test.php, i.php, pi.php, adminer.php, db.php.
De check:
for f in phpinfo.php info.php test.php adminer.php; do
curl -s -o /dev/null -w "$f: %{http_code}\n" "https://example.com/$f"
doneDe fix is verwijderen. Er is geen productiereden waarom deze bestanden in een publieke web root horen. Heb je echt een server-info endpoint nodig, plaats het dan achter HTTP basic auth op serverniveau en zet het op een niet voor de hand liggend pad.
Lockfiles en versiemanifesten
Deze is minder voor de hand liggend exploiteerbaar, maar hoort wel op de lijst. Een publieke composer.lock of package-lock.json vertelt iedereen met een CVE-feed precies welke versies van welke packages je draait. Vandaar is het één zoekopdracht naar een bekende ongepatchte kwetsbaarheid. Hetzelfde geldt voor composer.json, package.json en yarn.lock.
Geen van deze bestanden hoort door een browser gedownload te worden. Het zijn build-time artefacten. Blokkeer ze op bestandsnaam, of zet de project root boven de document root en laat het framework ze daar laden.
Debug logs
De zevende en laatste is degene die veroudert en vergeten wordt. WordPress' WP_DEBUG_LOG schrijft standaard naar wp-content/debug.log. Drupals error_log kan in sites/default/files/ belanden. Magento 1 liet in default installs zijn var/log/-directory wagenwijd open onder de document root staan. Elk van deze bestanden stapelt SQL queries, stack traces met volledige bestandspaden en af en toe credentials die onderweg gelogd zijn.
Zet debug logging uit in productie. Zet een developer het aan om iets specifieks te onderzoeken, dan hoort het log-pad buiten de web root te staan. De debugging-documentatie van WordPress zelf behandelt de relevante constanten. Het schoonste patroon is define('WP_DEBUG_LOG', '/var/log/wp/example-com.log'); met de directory in eigendom van de web user en onleesbaar voor al het andere.
Na de audit
De output van de zeven-minuten-curl-loop hoort na één middag opruimen zeven schone 404's te zijn. Borg die staat door dezelfde loop wekelijks vanaf een cron-job te draaien en de diff te mailen. De audit is goedkoop. De nalatigheid is wat geld kost.
Toen we Pier bouwden voor het bewerken van legacy sites via FTP en SFTP, deden we precies deze audit op elk project met de hand, dus hebben we hem als one-click sweep ingebouwd naast de MySQL editor. Elke verwijdering loopt via dezelfde version history als elke andere edit, zodat een te enthousiaste opruimactie één klik van ongedaan maken verwijderd is.
Het kleinste dat je vandaag kunt doen: plak de curl-loop bovenaan deze post in een terminal, richt hem op één site die je beheert en lees de statuscodes. Wat 200 teruggeeft, schrijf je op. Morgen is voor fixen.
— Vragen —
Waarom serveert Apache standaard .env-bestanden?
Apache blokkeert out-of-the-box alleen .htaccess en .htpasswd. Elk ander dotfile, inclusief .env en .git, wordt geserveerd tenzij je een expliciete FilesMatch-regel toevoegt in je config of top-level .htaccess.
Is een 403 op /.git/ genoeg om de directory veilig te noemen?
Nee. Veel servers blokkeren de directory listing maar serveren losse bestanden erbinnen alsnog, wat voor git-dumper voldoende is om de repo te reconstrueren. Verifieer door specifiek /.git/HEAD op te vragen en te bevestigen dat er een 404 terugkomt.
Waar horen debug logs te staan als niet in de web root?
Volledig buiten de document root. Op Linux werkt /var/log/wp/site.log met de web user als eigenaar en mode 0640 prima. Zet in WordPress WP_DEBUG_LOG op het absolute pad in plaats van het op true te laten.