Security
Legacy PHP-portaal audit: risicoscore voor chat-agent
Een industriële schoonmaker van €14M vraagt of we een chat-agent op hun verouderde PHP-portaal kunnen zetten. Het antwoord begint met een audit van vier pagina's, niet met een offerte.

Een dinsdagochtend in mei. De operations-manager bij een industriële schoonmaker van €14M in Eindhoven stuurt ons een link naar hun twaalf jaar oude PHP-klantportaal en één regel: "Kunnen we hier een chat-agent op zetten?"
De inlogpagina laadt. View-source toont jQuery 1.12.4 geminified onderaan en een PHP-sessioncookie zonder SameSite-attribuut. Het loginformulier post naar /login.php. Nergens in de DOM staat een CSRF-token. We offreren nog niet. We draaien de audit.
Waarom we eerst de audit doen, dan pas de offerte
Een chat-agent retrofitten op een verouderde portal is geen UI-project. Het is een autorisatieproject met een chatbox erbovenop. De agent gaat uiteindelijk een tool aanroepen. Die tool raakt vroeg of laat een van die 12 jaar oude endpoints. Als dat endpoint geschreven is toen CSRF-bescherming nog "de Referer-header checken" betekende, erft alles wat de agent doet dat trustmodel.
We hebben de afgelopen 14 maanden 31 van zulke portals geaudit. Drieëntwintig draaiden op PHP 7.4 of ouder. Achttien serveerden nog jQuery 1.x. Zeven hadden geen CSRF-token op enig state-changing endpoint. Vier hadden een session-fixation-gat in de SSO-bridge. We offreren geen retrofit voordat we weten in welk bakje de codebase valt.
PHP 7.4 bereikte end-of-life in november 2022. Dat is op zichzelf geen showstopper. Genoeg winstgevende Nederlandse mkb-bedrijven draaien op EOL-PHP en blijven dat doen tot de server omvalt. Het verandert wel hoe we de rest scoren, want er komt geen security-patch meer van upstream.
De portal die we steeds tegenkomen
De vorm is bijna altijd identiek. LDAP of Active Directory als bron voor medewerkers-logins. Een aparte klantportaal-tabel in MySQL voor klantlogins, met het klantnummer als join-key. Een SSO-bridge vanuit de marketingsite (vaak WordPress) die een PHPSESSID zet vóór de redirect. Formulieren gebouwd met jQuery 1.12 .ajax()-calls, geen token, geen SameSite-cookie. Een handvol endpoints die state muteren: orderstatus, factuur-download, adreswijziging, wachtwoord-reset, soms een offerte-aanvraag.
De LDAP-laag is het stuk dat als eerste bijt. In ruwweg de helft van de audits gebeurt de LDAP-bind in hetzelfde PHP-bestand dat ook het loginformulier rendert. Eén include-bug en de credentials van het service-account liggen open voor elke ingelogde gebruiker. Voordat een chat-agent ook maar in de buurt van een toolcall komt, moet die bind achter een interne service verdwijnen. Anders krijgt de tool van de agent per ongeluk de reikwijdte van het LDAP-service-account, de eerste keer dat iemand een pad in een template-parameter smokkelt.
De chat-agent die de klant wil, raakt altijd drie van deze endpoints. De audit bepaalt welke drie.
CSRF-token-hygiëne, gescoord van 0 tot 3
We geven een portal geen ja-of-nee. We geven 'm een score per state-changing endpoint:
- 0. Geen token, geen SameSite, accepteert GET voor state changes.
- 1. Geen token, Lax SameSite op PHPSESSID, accepteert alleen POST.
- 2. Token per session in een hidden input, rouleert nooit.
- 3. Token per request, rouleert bij uitgifte, server-side gevalideerd met
hash_equals.
Een score van 0 of 1 betekent dat het endpoint niet aan een tool-call-wrapper blootgesteld kan worden zonder een sidecar. Een 2 is werkbaar als de agent server-side draait en we elke toolcall proxyen via een verse, geauthenticeerde request. Een 3 is wat we offreren zonder security-supplement. De OWASP CSRF prevention cheat sheet is de referentie die we de engineer van de klant overhandigen als ze tegen de score pushen.
De eerste pas is grep. We proberen niet slim te zijn, we proberen te tellen:
cd /var/www/portal
# How many POST forms exist in the templates
grep -rEn 'method="post"' templates/ | wc -l
# How many of them mention any kind of token
grep -rEin '(csrf|_token|authenticity)' templates/ | wc -l
# Endpoints that still accept GET for mutations
grep -rEn '\$_GET\[' src/ | grep -iE '(save|update|delete|set)' | head -50
Is het tweede getal duidelijk kleiner dan het eerste, dan ligt de score over de hele linie op 0 of 1 en verandert het gesprek. We scopen geen chat-agent meer. We scopen een CSRF-retrofit met een chat-agent aan de andere kant.
Session-fixation in de SSO-bridge
De klassieke faalmodus is simpel. De marketingsite (WordPress) geeft een PHPSESSID uit aan een anonieme bezoeker. De bezoeker klikt op "Klantportaal". De portal accepteert de bestaande session-id. De SSO-callback schrijft de klantidentiteit in die session. Iedereen die een klant kan overhalen op een link te klikken met een voorgezette PHPSESSID, is nu ingelogd als die klant.
De fix is één regel bovenaan de SSO-callback, vóór er een identiteit weggeschreven wordt:
<?php
// /sso/callback.php
session_start();
session_regenerate_id(true); // delete the old session file
// only now do we write the identity
$_SESSION['klantnummer'] = $validatedUser['klantnummer'];
$_SESSION['logged_in_at'] = time();
De auditstap is een one-liner: grep -rn "session_regenerate_id" sso/. Komt er niets terug, dan is de bridge in vijf minuten te repareren en zetten we 'm op de scope. De OWASP session management cheat sheet dekt de rest van de bridge: cookie-flags, idle-timeout, absolute timeout.
Spant de SSO-bridge twee domeinen (portal.klant.nl en www.klant.nl), dan is session_regenerate_id alleen niet genoeg. De session is ondoorzichtig voor de marketingsite. Controleer dat de marketingsite niet zijn eigen cookie naar het domein van de portal schrijft via een gedeelde parent of een cross-subdomain SSO-bibliotheek.
Drie flows kiezen die een tool-call-wrapper overleven
De wrapper-vraag is niet "kan de LLM dit endpoint aanroepen." Het is "kan hij dit endpoint aanroepen zonder dat een klantnummer in een prompt-log eindigt."
Een klantnummer in een prompt-log is een AVG-bevinding. De provider bewaart prompts tot 30 dagen voor misbruikreview, ook met de no-train-vlag aan. Dat venster is lang genoeg om er een meldplichtige inbreuk van te maken als het klantnummer in dezelfde prompt-body aan andere persoonsgegevens hangt (naam, adres, ordertotaal). Een open-weights-model zelf hosten haalt de retentievraag weg, maar voegt een operationele vraag toe (patchen, monitoren, schalen, jailbreak-triage) waar bijna geen mkb de mensen voor heeft. Hoe dan ook: het securitymodel moet aannemen dat de prompt-body ergens gelogd wordt, en je ontwerpt redactie bij de wrapper.
We scoren elke kandidaat-flow op drie assen:
- Identifier-flow. Komt het klantnummer in de prompt-body, of alleen in de tool-argumenten? Tool-argumenten worden niet in de prompt gelogd; de body wel.
- Determinisme. Kan de tool als een getypeerd schema met drie velden uitgedrukt worden, of moet de agent vrije tekst van de klant parsen?
- Omkeerbaarheid. Als de toolcall fout is, kan de klant of een mens 'm dan binnen één werkdag terugdraaien?
De drie flows die bijna altijd overleven:
- Orderstatus opvragen. Read-only. Deterministisch. Het klantnummer reist als integer tool-argument, opgelost vanuit de session. De agent ziet de ruwe waarde nooit in zijn context.
- Factuur-PDF ophalen. Zelfde vorm. De tool retourneert een signed, short-lived URL; de agent stuurt de link door, nooit de PDF-body.
- Adreswijziging met expliciete bevestiging. Muterend maar omkeerbaar. De agent stelt het nieuwe adres voor als tool-argument, de portal stuurt een bevestigingsmail met een one-click-revertlink, de write gebeurt pas na de klik.
De flows die bijna nooit een eerste pas overleven:
- Wachtwoord-reset. Onomkeerbaar als 'm gekaapt wordt, en het recovery-pad leunt meestal op een e-mailadres dat de agent ook kan zien.
- Order plaatsen. Financieel, zonder fatsoenlijke rollback bij B2B-betalingstermijnen.
- Alles wat een e-mail naar de klant opstelt. De body van die mail wordt de volgende user-turn in de prompt, en het klantnummer lekt langs die route.
Klantnummer-redactie in de wrapper
We vertrouwen het model niet om te redacteren. Dat doen wij in de wrapper, voordat de prompt samengesteld wordt, en we verifiëren met een test die in CI draait:
import re
# Klantnummers in this portal follow K + 7 digits.
# Tune the pattern to the actual format on the client's database.
KLANTNUMMER = re.compile(r"\bK\d{7}\b")
def scrub_for_prompt(text: str) -> str:
"""Replace klantnummers in any free text that touches the prompt."""
return KLANTNUMMER.sub("[KLANT]", text)
def test_scrub():
assert scrub_for_prompt("Hoi, ik ben K1234567.") == "Hoi, ik ben [KLANT]."
assert scrub_for_prompt("Mijn nr K1234567 en K7654321.") == \
"Mijn nr [KLANT] en [KLANT]."
assert scrub_for_prompt("nothing here") == "nothing here"
Het echte klantnummer reist als tool-argument met de session-id als sleutel, niet via de prompt-content. De agent ziet [KLANT] in zijn contextvenster. De tool resolvet de identifier server-side vanuit de session. Mist de redaction-regex een format, dan faalt de test voordat de wrapper de deur uit gaat.
Redactie bij de wrapper is maar de helft van het werk. De applicatielogs zijn de andere helft. PHP-errorlogs, de access-log van de webserver, en elke APM-stack vangen het klantnummer op het moment dat het een grens oversteekt, en de meeste van die streams gaan off-host naar een SaaS-aggregator. Of je pre-processt de logregel bij de grens, of je scopet de retentie van de aggregator zo dat het klantnummer uitveroudert binnen het 72-uurs AVG-meldvenster. Wat je ook kiest: schrijf er een test voor, op dezelfde manier als voor de prompt-scrubber. Anders draait de volgende deploy met een debug-log het werk stilletjes terug.
De 30-minutenversie die je vandaag kunt draaien
Wil je geen auditor inhuren? Dit is de kortste pas die toch iets nuttigs oplevert.
Open de portal in Chrome. View source. Zoek de jQuery-versie onderaan. Staat er 1.x of 2.x, ga er dan vanuit dat elk formulier één stored-XSS-bug van een credential-lek af zit. Log in. Open het Network-tabblad. Submit een formulier dat state verandert. Kijk naar de request-payload. Zit er geen token-veld in, dan is de score op dat endpoint 0 of 1.
Inspecteer de session-cookie in DevTools. Is SameSite leeg of staat hij op None zonder Secure, scoor de portal dan over de hele linie op 0 tot het tegendeel blijkt. Klik vanuit de marketingsite in een incognitovenster op de SSO-link. Noteer de PHPSESSID-waarde vóór login. Log in. Check of de PHPSESSID veranderd is. Is dat niet zo, dan is de SSO-bridge in één regel PHP te fixen, maar vandaag stuk.
Lijst elk endpoint op dat state muteert. Markeer de read-only endpoints groen. Dat zijn de kandidaten voor je eerste drie flows. Pak degene die het saaist is (vrijwel altijd orderstatus). Bouw die eerst.
Het kost dertig minuten. Het is geen vervanging voor een code-audit. Het is een vervanging voor het offreren van werk dat je niet veilig kunt leveren.
Wat we de klant aan het eind overhandigen
Eén PDF, vier pagina's. Pagina één: de score-tabel, één regel per endpoint, met de score en het gat. Pagina twee: de bevindingen op de SSO-bridge, met de one-line-fix en de test die 'm bewijst. Pagina drie: de drie flows die we aanraden in te pakken, met de redaction-strategie en een klein architectuurdiagram. Pagina vier: de prijs voor de security-retrofit en de prijs voor de chat-agent, los van elkaar, zodat de klant elke helft kan uitstellen.
Die splitsing is belangrijk. De helft van de klanten die we auditten, besluit eerst de security-retrofit te doen en de agent zes maanden later toe te voegen. De andere helft besluit beide tegelijk. Een klant die besluit de retrofit over te slaan, hebben we nog nooit gehad. De audit reikt ze het bewijs aan in hun eigen taal.
Toen we vorig voorjaar de orderstatus-agent bouwden voor een Brabantse industriële schoonmaker, liepen we tegen het volgende aan: het verouderde /orders.php-endpoint accepteerde nog steeds GET-requests met het klantnummer in de query string. We hebben dat opgelost met een kleine Symfony-bridge vóór de legacy code, die per-request tokens uitgaf en het klantnummer uit elke logregel stripte voordat die de applicatieserver verliet. De AI-agent zag het ruwe klantnummer nooit in zijn prompt, en de legacy PHP hoefde niet aangepast te worden. Twee endpoints opnieuw ingepakt, één cookie-flag omgezet, de retrofit ging mee in dezelfde sprint als de agent.
Het kleinste dat je vandaag kunt doen: open je portal in Chrome, submit één formulier, en zoek in de request-body naar de string token. Staat hij er niet, dan is het antwoord op "kunnen we hier een chat-agent op zetten" niet nee. Het is "niet voordat het formulier eerst gefixt is."
Kern
Staat je CSRF-score onder de 2 en regenereert je SSO-callback de session-id niet, dan is de chat-agent het kleinste van je problemen.
FAQ
Wanneer moet je een legacy portal auditen voordat je een chat-agent toevoegt?
Altijd voordat je offreert. De toolcalls van de agent erven het trustmodel van het endpoint. Zijn CSRF en sessionhandling zwak, dan is de autorisatie van de agent ook zwak, en geen enkele prompt-engineering lost dat op.
Waarom is de end-of-life van PHP 7.4 belangrijk voor een chat-agent-retrofit?
Het blokkeert de retrofit niet, maar er komt geen upstream security-patch meer. Dat verandert hoe streng we CSRF-tokens en sessionhandling scoren, want de omringende code is niet meer per definitie veilig.
Hoe houd je een klantnummer uit een LLM-promptlog?
Geef 'm mee als tool-argument, nooit binnen de prompt-body. Redacteer dezelfde identifier in vrije tekst met een regex, en verifieer die redactie in CI voordat de wrapper naar productie gaat.
Welke drie flows overleven meestal een tool-call-wrapper?
Orderstatus opvragen, factuur-PDF ophalen, en adreswijziging met een expliciete bevestigingsstap. Ze zijn read-only of omkeerbaar, en de identifier hoeft nooit de prompt-body in.
Wat is de goedkoopste manier om session-fixation in een legacy SSO-bridge te fixen?
Zet session_regenerate_id(true) bovenaan de SSO-callback, vóór er een identiteit naar de session geschreven wordt. Eén regel PHP en één grep om te verifiëren dat het live staat.