Een product-naam, een zoekterm of een klantnotitie die zonder escaping in een template belandt, is genoeg voor een XSS-lek.
Een product-naam, een zoekterm of een klantnotitie die zonder escaping in een template belandt, is genoeg voor een XSS-lek. Het is een van de meest voorkomende bevindingen in Magento-maatwerk.
Cross-site scripting (XSS) betekent dat een aanvaller JavaScript in jouw pagina kan laten draaien. In een webshop is dat ernstig: het script kan sessies stelen, formulieren aanpassen of op de checkout betaalgegevens afvangen. In het admin-paneel kan het zelfs leiden tot overname van de beheeromgeving.
Magento heeft een goede set escaping-functies. Het probleem is bijna altijd dat ze niet, of op de verkeerde plek, worden gebruikt.
Waarom escaping per context werkt
De kernregel: escape bij uitvoer, en kies de functie die past bij de plek waar de data landt. Dezelfde string vraagt in HTML, in een attribuut, in een URL en in JavaScript om een andere behandeling.
In een .phtml template gebruik je de $escaper:
Een veelgemaakte denkfout: escapeHtml() overal gebruiken. Dat beschermt tekst tussen tags, maar niet automatisch een waarde binnen een onclick-attribuut of een script-blok. Daar heb je escapeJs() of beter, een andere aanpak nodig.
De fouten die we het vaakst zien
1. Helemaal geen escaping
<h2><?= $block->getTitle() ?></h2>
Als getTitle() data bevat die ooit door een gebruiker is ingevoerd, direct of indirect, is dit een lek. Dat geldt ook voor data uit een import, een ERP of een externe API. "Dat komt uit ons eigen systeem" is geen vrijbrief, want daar zit meestal ergens een invoerveld voor.
Het juiste patroon:
<h2><?= $escaper->escapeHtml($block->getTitle()) ?></h2>
2. Het verkeerde escape-type
<a href="<?= $escaper->escapeHtml($url) ?>">
escapeHtml laat javascript:alert(1) als URL staan. Gebruik escapeUrl() en valideer bovendien dat het schema http of https is als de bron onbetrouwbaar is.
3. Data in een inline script
<script>
var customer = '<?= $block->getCustomerName() ?>';
</script>
Een naam met een quote en een breekt hieruit. Beter is de data als JSON serialiseren met de ingebouwde helpers, of de waarde in een data-attribuut zetten dat je met escapeHtmlAttr() beschermt en vanuit JavaScript uitleest.
4. HTML bewust toestaan zonder filtering
Soms wil je echt HTML tonen, zoals een CMS-blok of een productbeschrijving. Dan gebruik je escapeHtml() met een lijst toegestane tags, of laat je het via de Magento-filters lopen. Een ongefilterde echo van HTML uit de database is een open deur.
5. Vertrouwen op de browser of op de front-end
Validatie in JavaScript is geen beveiliging. Een aanvaller stuurt gewoon rechtstreeks een verzoek. De server moet zelf controleren en bij uitvoer escapen.
Admin-XSS: onderschat
Veel teams denken: "het admin-paneel is alleen voor ons, dus XSS daar is niet erg." Dat klopt niet. Een voorbeeld van een scenario:
- Een bezoeker plaatst via het contactformulier of een orderopmerking een script.
- Een medewerker opent die order of dat bericht in de admin.
- Het script draait met de rechten van die medewerker, en kan bijvoorbeeld een nieuwe admin-gebruiker aanmaken of een script op de checkout plaatsen.
Dit heet stored XSS, en het is een bekende route naar volledige overname. Alle data die een klant kan invoeren en die in de admin verschijnt (adressen, opmerkingen, productreviews, formuliervelden), vraagt dezelfde escaping als de storefront.
Beperk de schade met 2FA, IP-beperking en rollen, zodat een overgenomen sessie minder kan.
Hyvä, Alpine en x-data
Op Hyvä-shops doet Alpine.js het werk van de oude JavaScript-laag. Dat is lichter en overzichtelijker, maar het brengt eigen valkuilen mee. Het gevaarlijkste patroon is PHP-data direct in een x-data of x-init attribuut plaatsen:
<div x-data="{ name: '<?= $block->getCustomerName() ?>' }">
Dit mengt twee contexten: een HTML-attribuut en een JavaScript-expressie. Een enkele quote in de naam breekt uit de string en voert code uit. Zelfs met escapeHtmlAttr() blijft de JavaScript-context in het attribuut een risico, omdat de browser eerst het attribuut decodeert en Alpine daarna het resultaat als code evalueert.
Veiligere werkwijze:
- Serialiseer data als JSON via
json_encodemet veilige vlaggen en plaats die in het attribuut metescapeHtmlAttr(). - Houd de logica in een Alpine-component en geef alleen data door, geen strings die code kunnen worden.
- Gebruik
x-textin plaats vanx-htmlzodra je tekst toont.x-htmlrendert HTML en is een directe XSS-route bij onbetrouwbare data.
Hyvä heeft ook een CSP-compatibele aanpak voor Alpine, zonder unsafe-eval. Dat is een sterk argument om je templates daarop in te richten, omdat een strikte Content Security Policy dan haalbaar blijft. Meer over Hyvä.
CSP als vangnet, niet als oplossing
Een Content Security Policy beperkt wat een geïnjecteerd script kan doen. Het lost de XSS niet op. Gebruik het als tweede verdedigingslaag. Escaping blijft de basis.
Code review checklist
Neem deze punten mee bij elke review van een .phtml of ViewModel:
- [ ] Staat er elke keer een
$escaper->escape...rond data die niet 100% vast is? - [ ] Past de escape-functie bij de context (HTML, attribuut, URL, JS)?
- [ ] Komen er variabelen in
-blokken,onclick, ofx-data/x-init? - [ ] Wordt HTML bewust getoond, en is er dan gefilterd?
- [ ] Wordt gebruikersinvoer uit de admin getoond zonder escaping?
- [ ] Zijn
$block->escapeHtml()-aanroepen vervangen door$escaper(nieuwe stijl)? - [ ] Is er een
|raw-achtige uitzondering of@noEscapeannotatie? Dan moet er een reden bij staan.
Magento's coding standards (PHPCS) hebben regels die ontbrekende escaping in templates opsporen. Draai die in je pipeline. Het vangt veel, maar niet alles, dus een menselijke review blijft nodig.
Hoe wij het in een audit zoeken
In een security audit combineren we geautomatiseerde en handmatige stappen:
- Statisch zoeken in alle
.phtml-bestanden naar uitvoer zonder$escaper, en naar variabelen in script- en attribuutcontext. - Invoer volgen: welke velden kan een bezoeker vullen, en waar komt die data weer terug, in de storefront én in de admin?
- Handmatig testen met een onschuldige testtekenreeks die speciale tekens bevat, om te zien waar het onbehandeld terugkomt.
- Extensies nakijken, want veel XSS-lekken zitten in third-party code. Zie extensies en supply chain-risico.
Dit past binnen het OWASP-kader. XSS valt onder Injection (A05 in de OWASP Top 10:2025). Meer over de lijst vind je op OWASP en je webshop.
Wanneer is dit overkill?
Een standaard Magento zonder maatwerk heeft dit probleem nauwelijks, want de core is goed afgedicht. De risico's zitten in custom thema's, eigen modules en oudere extensies. Gebruik je alleen goed onderhouden extensies en een standaardthema, dan is een paar steekproeven voldoende. Veel maatwerk? Dan is een gerichte template-review de moeite waard.
Een voorbeeld van een veilige herschrijving
Een onveilige snippet in een custom template:
<div class="welkom" data-naam="<?= $block->getName() ?>">
Hallo <?= $block->getName() ?>
<a href="<?= $block->getReturnUrl() ?>">Terug</a>
<script>var naam = '<?= $block->getName() ?>';</script>
</div>
Vier uitvoerplekken, vier contexten, nul escaping. De veilige variant:
<div class="welkom" data-naam="<?= $escaper->escapeHtmlAttr($block->getName()) ?>">
Hallo <?= $escaper->escapeHtml($block->getName()) ?>
<a href="<?= $escaper->escapeUrl($block->getReturnUrl()) ?>">Terug</a>
</div>
Het inline script is weggehaald. De naam staat al in een data-naam attribuut en kan vanuit een extern JavaScript-bestand worden uitgelezen. Dat is veiliger en past bovendien beter bij een strikte CSP.
Waar XSS in de praktijk vandaan komt
Niet alleen formulieren zijn een bron. Denk aan:
- Zoekresultaten die de zoekterm terugtonen ("Geen resultaten voor ...").
- Productreviews en vragen van klanten.
- Adresvelden en orderopmerkingen, die later in e-mails en de admin verschijnen.
- Querystring-parameters die in een pagina worden teruggegeven, zoals een
?utm_campaign=die in de HTML belandt. - Import- en feedbestanden met productdata van leveranciers.
- Berichten van externe systemen, zoals een ERP of marktplaats.
Voor elke bron geldt dezelfde vraag: komt deze waarde ergens terug op een pagina, en wordt hij daar geëscaped?
Reflected, stored en DOM-based
Kort uitgelegd, omdat je in een rapport deze termen tegenkomt:
- Reflected XSS: de invoer zit in het verzoek (bijvoorbeeld een link) en komt direct terug in het antwoord. De aanvaller moet het slachtoffer op die link laten klikken.
- Stored XSS: de invoer wordt opgeslagen en later aan anderen getoond. Gevaarlijker, omdat het slachtoffer niets hoeft te doen behalve de pagina openen.
- DOM-based XSS: het lek zit in JavaScript dat zelf onveilig met data omgaat, bijvoorbeeld via
innerHTML. De server ziet de aanval niet eens.
Escaping in PHP-templates dekt de eerste twee. Voor de derde moet je je JavaScript nakijken en innerHTML vervangen door textContent waar het kan.
Veelgestelde vragen
Wat is het verschil tussen escapeHtml en escapeHtmlAttr?
escapeHtml() is voor tekst tussen HTML-tags. escapeHtmlAttr() is voor waarden binnen een attribuut en behandelt aanhalingstekens strenger. Gebruik altijd de variant die past bij de plek waar de data komt te staan.
Moet ik alles escapen wat uit mijn eigen database komt?
Ja, als de data ooit door een gebruiker, importbestand of externe bron is ingevoerd. Escape bij uitvoer, niet bij opslag. Zo blijft de data bruikbaar en is elke uitvoerplek beschermd.
Is XSS in het admin-paneel echt een probleem?
Ja. Stored XSS in de admin draait met de rechten van de beheerder die de pagina opent, en kan leiden tot overname van de shop. Klantdata die in de admin verschijnt, moet net zo goed worden geëscaped.
Is Hyvä veiliger tegen XSS dan Luma?
Hyvä heeft minder JavaScript en een CSP-compatibele Alpine-aanpak, wat helpt. Maar XSS ontstaat in je templates, niet in het framework. Ook op Hyvä moet je data in x-data en x-html zorgvuldig behandelen.
Vervangt een CSP escaping?
Nee. CSP beperkt de schade van een geïnjecteerd script, maar het lek blijft bestaan. Escaping voorkomt dat het script er komt.
Wil je dat iemand je templates en extensies nakijkt op XSS? Dat doen we binnen een security audit. Neem contact op via /contact.

Geschreven door Ruthger Idema
15+ jaar ervaring in e-commerce development. Gespecialiseerd in Magento, Shopify en Laravel maatwerk.
Meer over ons team →


