— Artikel — № 091

091 —WordPress

WordPress multisite-lek: een vergeten blogs.dir symlink

Een WordPress multisite serveerde de PDF's van klant B onder de URL's van klant A. De oorzaak: een symlink ouder dan het bureau. Dit is de trace.

Bovenaanzicht ruitjespapier bestandsboom, manilla UPLOADS-map, messing SYMLINK-plaat, rode lakzegel LEAK op linnen.
Hero · gestileerd stilleven№ 091

Het eerste bericht kwam binnen om 15:47 op een dinsdag. Een Nederlands bureau waar we mee samenwerken draait een WordPress multisite met 14 sites voor een keten regionale kranten. Een van hun redacteuren had de mediabibliotheek geopend op site 7 en zag daar een PDF die ze niet kende: een salarisoverzicht, gemarkeerd als vertrouwelijk, behorend bij site 4. Ze ververste. Hij stond er nog. Ze opende het bestand. Het werd gewoon weergegeven. Ze klapte haar laptop dicht en belde de studio lead.

Om 16:10 zaten we op een screen-share. Om 16:55 hadden we de oorzaak te pakken: een blogs.dir symlink die in 2014 verwijderd had moeten worden bleek nog steeds te resolven, en een .htaccess rewrite die voor de oude indeling was geschreven volgde hem vrolijk. Dit is de trace, uitgeschreven zodat je je eigen versie ervan vindt voordat een redacteur dat doet.

Het symptoom dat de redacteur zag

De mediabibliotheek van site 7 toonde een thumbnail en bestandsnaam van een bestand dat elf maanden eerder op site 4 was geüpload. De URL in de browser was het domein van site 7. Het bestand dat geserveerd werd was de PDF van site 4. Dezelfde bytes, dezelfde hash, geserveerd onder de hostnaam van de verkeerde tenant, achter de login van de verkeerde tenant.

Multisite slaat uploads per blog op. Op een moderne installatie (3.5+) betekent dat wp-content/uploads/sites/<blog_id>/YYYY/MM/. Op installaties die vóór 3.5 zijn aangemaakt en in plaats zijn ge-upgrade, krijg je daarnaast ook nog de legacy layout: wp-content/blogs.dir/<blog_id>/files/YYYY/MM/. De WordPress multisite documentatie verwijst nog steeds naar deze splitsing omdat zoveel installaties van vóór de wijziging dateren.

De installatie van dit bureau was oorspronkelijk in 2012 opgezet. Hij was ge-upgrade, in 2017 naar een nieuwe host gemigreerd, en in 2021 nogmaals gemigreerd. Bij elke migratie liep rsync -a over wp-content. Bij elke migratie bleef alles eronder behouden, inclusief een symlink waar in negen jaar niemand meer aan gedacht had.

De trace

Het eerste wat we deden was de URL in kwestie aanroepen met curl -I vanaf een schone shell:

$ curl -sI https://site7.example.nl/files/2024/08/salaris-overzicht.pdf
HTTP/2 200
content-type: application/pdf
content-length: 184223
last-modified: Wed, 14 Aug 2024 09:11:42 GMT
x-served-by: site7-pool

200, geserveerd door de pool van site 7, het bestand bestond echt op disk onder dat pad. De rewrite klopte dus, het filesystem niet. We gingen op de machine zelf kijken.

$ cd /var/www/wp-content/blogs.dir
$ ls -la
drwxr-xr-x  4 www-data www-data 4096 Jun 12  2014 .
drwxr-xr-x 12 www-data www-data 4096 Mar  4  2024 ..
drwxr-xr-x  3 www-data www-data 4096 Aug 14  2024 4
lrwxrwxrwx  1 root     root       12 Jun 12  2014 7 -> ../blogs.dir/4

Daar stond het. De blogs.dir entry van site 7 was een symlink naar de directory van site 4, gedateerd 12 juni 2014, eigendom van root. Niemand van het huidige team werkte in 2014 bij het bureau. Git trackte wp-content niet. Niemand wist ervan.

Waarom de rewrite hem nog steeds raakte

WordPress 3.5 veranderde waar uploads staan, maar behield een .htaccess rewrite voor backward compatibility op installaties die in plaats zijn ge-upgrade. Op deze server zagen de multisite rewrites in wp-content/.htaccess er zo uit:

RewriteEngine On
RewriteBase /
RewriteRule ^([_0-9a-zA-Z-]+/)?files/(.+) wp-includes/ms-files.php?file=$2 [L]

Dat is de canonieke regel die de WordPress .htaccess reference meelevert. Hij routeert /files/<path> op een subsite via ms-files.php, die vervolgens kijkt naar het huidige blog ID en het bestand serveert uit blogs.dir/<id>/files/. Als blogs.dir/7 een symlink is naar blogs.dir/4, serveert ms-files.php de content van site 4 vrolijk onder de hostnaam van site 7, en dat gebeurt binnen een ingelogde session voor site 7, dus het bestand wordt behandeld als in-tenant.

Apache volgt symlinks standaard, tenzij je Options -FollowSymLinks of SymLinksIfOwnerMatch hebt gezet. De meeste managed hosts laten FollowSymLinks aanstaan omdat de helft van het WordPress-ecosysteem er zonder kapotgaat. De eigen documentatie van Apache is duidelijk over de trade-off: SymLinksIfOwnerMatch had dit afgevangen, omdat de symlink eigendom was van root en het doel van www-data. Het bureau had hem nooit gezet.

Waarom de symlink überhaupt bestond

We groeven door het migratieticket uit 2014. De oorspronkelijke installatie had twee sites die een redactionele kalender deelden, en iemand had de directory van het ene blog naar het andere gesymlinkt zodat een embed-plugin dezelfde PDF's kon vinden zonder een tweede upload. De plugin werd zes maanden later verwijderd. De symlink bleef staan. De rsync bij de migratie van 2017 behield hem. De migratie van 2021 behield hem opnieuw. Tussendoor werd site 7 (oorspronkelijk een van het gedeelde paar) hergebruikt voor een andere regionale krant, en op een gegeven moment werd er een nieuwe uploads/sites/7/ boom naast de inmiddels betekenisloze blogs.dir/7 symlink aangemaakt. Nieuwe uploads gingen naar de nieuwe boom. De handvol URL's die nog naar /files/ wezen, gingen naar de symlink. Op de meeste van die URL's klikte niemand meer. Tot een redacteur dat wel deed.

De fix en de audit

De directe fix was vier regels:

$ cd /var/www/wp-content/blogs.dir
$ rm 7
$ find . -maxdepth 1 -type l -printf '%p -> %l\n'
$ # (geen output, geen andere cross-tenant links)

Daarna controleerden we de rest. Drie dingen om te checken op elke multisite ouder dan WordPress 3.5, of die daarvan is gemigreerd:

  1. Elke entry in wp-content/blogs.dir/ hoort een directory te zijn die eigendom is van de webserver-user. Alles wat een symlink is, is verdacht. find wp-content/blogs.dir -maxdepth 2 -type l vindt ze in één pass.
  2. De tabel wp_blogs moet overeenkomen met de directories op disk. SELECT blog_id, domain, path FROM wp_blogs; naast ls wp-content/blogs.dir/ en ls wp-content/uploads/sites/ legt wezen aan beide kanten bloot.
  3. Als je de legacy /files/ route niet nodig hebt, zet dan define( 'NOBLOGREDIRECT', '...' ); en haal de ms-files.php rewrite uit .htaccess. De multisite admin guide beschrijft de overstap. De meeste installaties die na 3.5 ge-upgrade zijn, hebben hem niet nodig.

We voegden ook Options -FollowSymLinks +SymLinksIfOwnerMatch toe aan de vhost. Dat alleen had de lek voorkomen, omdat de eigenaar van de bungelende symlink niet overeenkwam met die van zijn doel. Het is een wijziging van één regel die zichzelf terugbetaalt zodra een migratie voor het eerst een oud artefact meeneemt.

Wat de post-mortem feitelijk zei

De interne write-up van het bureau noemde drie bijdragende oorzaken en één hoofdoorzaak. Bijdragend: geen audit van wp-content bij migratie, geen symlink-check in monitoring, FollowSymLinks op de Apache-default gelaten. Hoofdoorzaak: een beslissing uit 2014 om uploads via een symlink te delen werd nooit gedocumenteerd en nooit teruggedraaid.

Dat is het deel dat legacy sites lastig maakt. De bug zat niet in de code die iemand dat decennium had geschreven. Hij zat in een filesystem-artefact ouder dan het team, door elke migratie meegekopieerd, onzichtbaar voor elke pluginscan, en pas zichtbaar geworden toen één redacteur een bestandsnaam zag die ze niet herkende.

Toen we Pier bouwden, bleven we tegen varianten hiervan aanlopen: drift op plekken zonder eigenaar, op hosts waar niemand inlogt. Het werd uiteindelijk zo dat elk bestand dat Pier aanraakt door zijn version history gaat, en dat de bestandsbrowser symlinks en hun doelen inline naast de gewone boom toont, zodat je kruisingen ziet zonder ls -la te hoeven draaien.

Als je vandaag één ding doet, draai dan find wp-content -type l op elke WordPress multisite die je beheert. Het kost ongeveer dertig seconden per installatie. Alles wat eruit komt, verdient een uitleg.

— Vragen —

Wordt blogs.dir nog gebruikt in een moderne WordPress multisite?

Alleen op installaties die vanuit pre-3.5 zijn ge-upgrade. Nieuwe installaties gebruiken wp-content/uploads/sites/<id>/. Ge-upgrade installaties houden blogs.dir en de ms-files.php rewrite vaak aan voor backward compatibility.

Had het uitzetten van FollowSymLinks dit voorkomen?

Ja. SymLinksIfOwnerMatch alleen had het al geblokkeerd, omdat de bungelende symlink eigendom was van root en het doel van www-data. Apache weigert in dat geval de link te volgen.

Hoe vind ik verouderde symlinks op een multisite?

Draai find wp-content -type l vanaf de install-root. Vergelijk de resultaten met wp_blogs om te bevestigen dat elke link wijst waar de huidige tenant-mapping verwacht.