← Blog

Joomla

Joomla 3.10 redden: 8.000 leden, kapotte btw, dode SSO

Een vereniging met 8.000 leden gaf ons vrijdagmiddag om 16 uur de sleutels van een Joomla 3.10-site. De penningmeester had één vraag: waar zijn de btw-facturen van vorig jaar?

Jacob Molkenboer· Oprichter · A Brand New Company· 5 aug 2024· 11 min
Gesloten leren logboek met messing sleutel aan touw, groene indexkaart, rode lakzegel op botpapieren bureau.

Een beroepsvereniging met 8.000 leden gaf ons vrijdagmiddag om 16 uur de sleutels van hun ledenportaal. Het bestuur had drie maanden lang iemand gezocht om het stokje over te nemen van de vorige developer, die eind 2024 stilletjes was gestopt met mail beantwoorden. De penningmeester had één vraag, en die stelde ze nog voor we hadden ingelogd: waar zijn de btw-facturen van vorig jaar?

De site draaide op Joomla 3.10.12. Leden logden in via een custom SSO-module die praatte met een LDAP-server die de vereniging zelf beheerde. Betalingen liepen via een externe plugin die bonnetjes naar een folder op disk schreef en ze per mail naar leden stuurde. Ergens in 2023 was die mail gestopt met afvuren, en niemand had het door totdat de boekhouder om de jaarafsluiting vroeg.

Dit is het verhaal van de eerste twee weken. Geen schone overwinning. Het is de volgorde waarin we de dingen hebben gedaan, en de dingen die we achteraf liever eerder hadden gedaan.

Het eerste uur: read-only, geen heldendaden

Voor we iets aanraakten, maakten we een volledige snapshot. Een mysqldump van de database, een tar van de webroot, en een kopie van de slapd-config van de LDAP. We trokken het via SFTP naar een lokaal versleuteld volume en controleerden de dump met een checksum. Daarna vroegen we de hostingpartij om read-only credentials en rouleerden we elk ander wachtwoord dat we hadden gekregen.

ssh association-prod \
  'mysqldump --single-transaction --quick --routines --triggers \
     -u backup_ro assoc_joomla | gzip -9' \
  > assoc_joomla-$(date +%Y%m%d).sql.gz

rsync -aH --numeric-ids \
  association-prod:/var/www/joomla/ ./webroot-snapshot/

Dit deden we voor we ook maar één regel PHP hadden gelezen. Joomla 3.10 ging in augustus 2023 end-of-life, wat betekent dat elke nog niet ontdekte bug op die server nu een permanente eigenschap is, tenzij we hem verplaatsen. Een schone snapshot is het enige dat tussen een redding en een ramp staat.

Dezelfde avond zetten we ook een staging-kopie neer op een aparte host, achter HTTP basic auth, hersteld vanuit de dump en gericht op een wegwerp-MySQL-instance. Elke wijziging die we de komende twee weken voorstelden werd daar eerst gerepeteerd, met dezelfde PHP-versie, dezelfde OpenSSL-build en dezelfde tijdzone. Drie van onze geplande ingrepen faalden op staging op een manier waarop ze in development niet zouden zijn gefaald, waaronder een Joomla cache-rebuild die veertig minuten bleef hangen omdat de staging-disk op trage rotational storage stond. Dat ontdek je liever op staging dan om middernacht op productie.

Waarschuwing

Als bij de overdracht de woorden "de vorige developer is weg" vallen, ga er dan van uit dat niets in het draaiende systeem is wat de documentatie beweert. Eerst snapshotten, daarna lezen.

De SSO in kaart brengen voor je iets verandert

De custom SSO was een Joomla authentication plugin in plugins/authentication/assocldap/. Hij bond aan LDAP als een service-account, zocht de gebruiker op via een attribuut genaamd memberNumber, en bij succes schreef hij een Joomla-sessie en een langlevende cookie genaamd assoc_sso. Diezelfde cookie werd ook geaccepteerd door een aparte Drupal-eventsite op een subdomein, en zo werkte single sign-on over beide properties heen.

De cookie zelf zag er zo uit toen we er een uit een testlogin uitpelden:

assoc_sso = base64(memberNumber|expiry_unix) . '|' . hmac_sha1(SECRET, base64_payload)

We hebben dit een halve dag op papier uitgetekend voor we er iets aan deden. Twee dingen sprongen eruit.

Ten eerste was de cookie ondertekend met HMAC-SHA1 met een secret die hardcoded in het plugin-bestand stond. Iedereen die ooit de repo voor lokaal development had gecloned, had die secret. Geen Git-tags, geen rotation-historie, en geen overzicht van wie de codebase sinds 2017 had opgehaald. Ten tweede zette de plugin Joomla's ingebouwde 2FA uit voor elke gebruiker die via SSO binnenkwam, met een comment dat luidde // TODO: re-enable once LDAP TOTP works. Die TODO dateerde uit 2019.

Er moest ook een parallel gesprek komen met de vrijwilliger die het Drupal-eventsubdomein beheerde. Hun site verifieerde dezelfde cookie met een kopie van dezelfde secret, jaren eerder in een sites/default/settings.php-override geplakt. Elke rotatie moest op beide servers binnen hetzelfde onderhoudsvenster gebeuren, anders zouden leden op de ene site uitgelogd zijn en op de andere stilletjes nog ingelogd blijven. We coördineerden via een gedeeld notitiedocument en zetten de swap op een rustige zondagochtend in de eventsagenda.

We hebben de plugin niet eruit gerukt. We schreven een document van één pagina waarin stond wat hij deed, waar de secret leefde, en welke gebruikersklassen 2FA omzeilden. Daarna deelden we dat met het bestuur voor we een wijziging voorstelden. Een legacy auth-systeem is een dragende muur. Eerst in kaart brengen, dan pas snijden.

De verdwenen btw-facturen

De betaalplugin was een fork van een commerciële Joomla-extensie waarvan de vendor in 2022 was verdwenen. De fork leefde in plugins/system/assocpay/ en handelde SEPA-incasso's voor contributies af. Hij genereerde PDF-facturen met FPDF en mailde ze via Joomla's JMail-wrapper.

We grepten de logs voor de periode die de penningmeester aanwees. De PDF's werden nog steeds naar disk geschreven. De databaserij die elke factuur als sent = 1 markeerde werd nog steeds bijgewerkt. Maar de mail.log op de server liet vanaf maart 2023 geen uitgaand verkeer meer zien voor het facturatieadres.

De bug zat drie regels diep. De plugin gebruikte JMail::isHtml(true), hing daarna de PDF aan, en riep toen send() aan. In een Joomla 3.10 point-release uit begin 2023 was de volgorde van attachment-processing in de PHPMailer-bundle veranderd. De setBody()-aanroep van de plugin gebeurde nadat de attachment was geregistreerd, en de nieuwe PHPMailer verwierp de body stilletjes als malformed en gaf alsnog true terug. De plugin logde succes. Geen mail verliet het gebouw.

// Wat de plugin deed. Werkte tot begin 2023, daarna stille failure.
$mailer = JFactory::getMailer();
$mailer->isHtml(true);
$mailer->addAttachment($pdfPath);   // attachment eerst geregistreerd
$mailer->setBody($htmlBody);        // body daarna gezet: intern afgewezen
$ok = $mailer->Send();              // geeft true terug. niets daadwerkelijk verstuurd.

if ($ok) {
    $db->setQuery("UPDATE assoc_invoices SET sent = 1 WHERE id = {$id}")
       ->execute();
}

Dit soort failure vind je alleen door de echte PHPMailer-source te lezen, niet de wrapper van de plugin. De zorg van de penningmeester was terecht. Achttien maanden aan facturen waren gegenereerd, in de database als verzonden gestempeld, en nooit afgeleverd.

Wat we met de facturen hebben gedaan

We hebben niet geprobeerd om 18 maanden aan facturen in één keer met terugwerkende kracht uit te mailen. Dat had elk spamfilter in Nederland getriggerd en van een klein probleem een publiek probleem gemaakt. In plaats daarvan:

  1. We bevestigden met de boekhouder welke facturen wettelijk opnieuw moesten worden uitgereikt onder de Nederlandse btw-regels en welke alleen handig waren om te hebben. De eisen van de Belastingdienst aan factuurbewaring zijn specifiek, en "de PDF staat op een server" is niet hetzelfde als "de factuur is uitgereikt".
  2. We schreven een eenmalig script dat elke factuur opnieuw genereerde met een duidelijk Duplicaat-watermerk en een begeleidende noot die de vertraging uitlegde.
  3. We faseerden de heruitgave over zes weken, gebatcht per ledencohort, met een contactadres dat naar een mens in het bestuur ging.

De begeleidende noot was drie korte alinea's. Erin stond dat uit de administratie van de vereniging bleek dat elke factuur op zijn oorspronkelijke datum was gegenereerd maar dat een technisch defect de aflevering had verhinderd, dat het duplicaat voor btw-doeleinden de oorspronkelijke datum droeg, en dat leden die een correctie nodig hadden voor al ingediende boekhouding konden reageren naar een aangewezen adres. Op 8.000 leden kregen we twee zulke reacties. Beide werden in één mail per stuk afgehandeld. Het bestuur tekende de noot. Wij niet.

Eerst stabiliseren, dan migreren

De verleiding bij zo'n klus is om meteen een volledige rebuild op een moderne stack voor te stellen. Joomla 4 of 5, een schone herimplementatie van de SSO tegen OIDC, een gehoste betaalprovider, the works. Dat gesprek moet er wel komen. Het is alleen niet het gesprek voor week één.

In week één en twee deden we de kleinste set wijzigingen die de site veilig genoeg maakten om nog een kwartaal te laten draaien:

  • Het wachtwoord van het LDAP-service-account en de secret van de SSO-cookie gerouleerd. Iedereen geforceerd opnieuw laten inloggen. Twee dagen van tevoren in het Nederlands gecommuniceerd, met een screenshot van hoe het nieuwe loginscherm eruit zou zien.
  • De mail-volgordebug van de betaalplugin gepatcht. De fix gebackport in een getagde branch in een verse Git-repo, omdat de vorige developer per SFTP deployde en er geen versiebeheer was. Elk bestand in de webroot kreeg een initial commit met datum 2026, met een korte noot dat eerdere historie onbekend was.
  • De site achter een WAF gezet met een regel die de bekende CVE-2023-23752 webservice-info-exposure blokkeerde, waar de ongepatchte 3.10-install kwetsbaar voor was tot we de schoonmaak hadden afgerond. Diezelfde regelset blokkeerde de vier meest voorkomende Joomla 3 brute-force user-agents aan de rand, wat admin-loginpogingen van ongeveer 9.000 per dag terugbracht naar onder de 200.
  • Een runbook geschreven dat het bestuur zelf kon lezen. Gewoon Nederlands, geen jargon, één pagina. Erop stond wie te bellen als de site eruit lag, hoe je hem zonder developer in onderhoudsmodus zette via het controlepaneel van de hostingpartij, en waar de laatste snapshot stond. Die ene pagina sloot ongeveer zes maanden aan terugkerende agendapunten.

Pas daarna openden we het gesprek over een migratiebestemming. Joomla 5, een aparte identity provider, en een betaalprovider met een echte API. Dat werk loopt nu. Het zal ongeveer vier maanden duren. In week één was het gedoemd geweest.

Conclusie

Bij een legacy-redding is de eerste oplevering geen roadmap. Het is een site die veilig kan blijven draaien terwijl jij er een schrijft.

Hoe dit werk er in de praktijk uitziet

Niets hiervan was glamoureus. Het waren vier dagen andermans PHP lezen, twee dagen aan de telefoon met een penningmeester en een boekhouder, één lange avond met een packet capture om LDAP-binds mee te bekijken, en heel veel zorgvuldig notuleren. We factureren het op dezelfde manier als al het andere, en we vertellen klanten vooraf dat de eerste twee weken diagnose zijn, geen oplevering. Het contract voor die weken benoemt precies dat: een snapshot, een inventaris van elke plugin met de laatst bekende werkende versie, een geschreven kaart van de auth-flow, en een herstelplan met ruwe inschattingen. Geen nieuwe features. Geen redesign. Dat de klant met dat contract akkoord gaat, is bij een redding de enkele beslissing met de grootste hefboomwerking.

Toen we vorig jaar dezelfde oefening deden op een ledenplatform voor een Nederlandse branchevereniging, was de failure mode bijna identiek: een betaalintegratie die door een vendor-update buiten compliance was gedreven, en een custom auth-laag waar niemand aan durfde te komen. We losten het op dezelfde manier op: snapshotten, in kaart brengen, stabiliseren, en pas dan de legacy migratie voorstellen. De rebuild is het makkelijke deel. Het moeilijke deel is het recht verdienen om hem te doen.

Als jij zo'n site hebt geërfd

Besteed vandaag één uur aan één ding. Open de mail-verzendcode van je betaalplugin en grep naar de laatste keer dat iemand de volgorde van setBody, addAttachment en send heeft veranderd. Check daarna de uitgaande log van je mailserver voor de datum waarop dat bestand voor het laatst is aangeraakt. Als die twee data geen consistent verhaal vertellen, heb je hetzelfde probleem als wij, en je hebt het nu.

Kern

Bij een legacy-redding is week één geen roadmap. Het is een snapshot, een kaart van het auth-systeem, en een site die veilig kan blijven draaien terwijl jij die roadmap schrijft.

FAQ

Is Joomla 3.10 in 2026 nog veilig om te draaien?

Nee. Joomla 3.10 is in augustus 2023 end-of-life gegaan. Er komen geen securitypatches meer. Als je hem op korte termijn echt moet laten draaien, zet hem dan achter een WAF en plan een migratie naar Joomla 5 of een andere stack.

Kan een betaalplugin facturen echt als verzonden markeren zonder ze te versturen?

Ja. Als de plugin de return value van zijn eigen wrapper checkt in plaats van de onderliggende mailer, en die wrapper errors opslokt, krijg je databaserijen waarin sent=1 staat terwijl er nooit een mail de server uit is gegaan.

Moeten we de SSO eerst herbouwen voordat we Joomla migreren?

Meestal niet. Documenteer wat de SSO doet, rouleer de secrets, en migreer Joomla eerst op de bestaande auth-flow. Identity en het CMS tegelijk vervangen vermenigvuldigt het risico voor weinig winst.

Hoe lang duurt zo'n Joomla legacy-redding?

Diagnose en stabilisatie duurt meestal twee tot drie weken. Een volledige migratie naar een actuele stack met een nieuwe identity provider en betaalintegratie loopt meestal drie tot vijf maanden, afhankelijk van het aantal leden en integraties.

joomlalegacy sitesmigrationphpsecuritycase study

Iets bouwen?

Start een project