
← Terug naar blog
Hoe XSS de DOM bereikt, welke browser APIs sinks zijn, en mitigaties die in productie werken—sanitisatie, CSP, cookies en CSRF pairing.
Als een string wordt geïnterpreteerd als markup of script in plaats van inert text, dan wordt die inhoud uitgevoerd als code in de oorsprong van je pagina—met dezelfde privileges als je bundel. Dat is XSS: geen styling bug, maar een verwarde deputy tussen data en instructies. Dit artikel is de technische kern—aanvalmechanica, sinks, fixes die schalen, plus een uitvoerbare frontend-xss-demo (Vite + React op main; live: xss.lucascoliveira.com) en verwijzingen naar deze Next.js repository waar headers en MDX het standaard risico verminderen.
Scripts die in je pagina zijn geïnjecteerd, worden uitgevoerd met dezelfde oorsprong als je bundel. In de praktijk betekent dit vaak:
document.cookie zichtbaarheid voor cookies die niet zijn gemarkeerd als HttpOnly (sessie-diefstal / fixatie chaining).fetch() / XMLHttpRequest naar je API met omgevingsreferenties als cookies worden verzonden en CORS het toestaat—of exfiltratie van tokens die zijn opgeslagen in localStorage / sessionStorage als je app ze daar plaatst.Dus de mitigatie is niet "gebruik React"—het is nooit het toewijzen van door de aanvaller gecontroleerde bytes aan een sink die HTML parseert of script uitvoert, tenzij een beoordeelde pijplijn ze heeft gereduceerd tot een veilig type.
Opgeslagen XSS — Niet-vertrouwde HTML (of een payload die HTML wordt na sjabloonuitbreiding) wordt persistent gemaakt (DB, cache, zoekindex). Elke gebruiker die dat record laadt, raakt de sink. Frontend-impact: de eerste weergave die innerHTML = row.body of equivalent uitvoert zonder sanitisatie, voert de payload uit.
Gereflecteerde XSS — De payload blijft nooit persistent; het stuitert terug van de server of routinglaag in het antwoord. Klassiek: ?q=<script>…</script> gereflecteerd in de HTML zonder codering. SPA-equivalent: location.search of hash parsed client-side en geschreven in de DOM zonder codering. De oplossing is hetzelfde: behandel URL-afgeleide strings als data; als je ze moet reflecteren, codeer ze voor de context (zie hieronder).
DOM-gebaseerde XSS — Het serverantwoord is "schoon", maar client-side script leest door de aanvaller gecontroleerde invoer (location, referrer, postMessage, WebSocket-berichten) en geeft het door aan een sink. Voorbeeld: eval("handle" + location.hash.slice(1)) of element.innerHTML = decodeURIComponent(...). Statische analyse van sjablonen is niet genoeg; je moet elke pad van niet-vertrouwde invoer naar sink controleren.
Dit zijn de gebruikelijke schuldigen in React/SPA-codebases:
| Sink | Risico |
|---|---|
element.innerHTML, insertAdjacentHTML | Parseert HTML; elke tag/gebeurtenishandler die je toestaat, kan script uitvoeren. |
dangerouslySetInnerHTML | Dezelfde als boven—React sanitiseert niet. |
document.write | Dezelfde. |
eval, new Function, setTimeout(string) | Directe scriptuitvoering. |
javascript: URLs in href / src | Navigatie of bronbelasting die wordt uitgevoerd als script-URL. |
postMessage handlers die eval of HTML instellen vanuit event.data | XSS als origin niet wordt gevalideerd of event.data een sink bereikt zonder een veilig contract—niet alleen "verkeerde window" fouten. |
Geen sink per default: textContent, createTextNode, React's normale kindtekst, attributen die React behandelt als strings wanneer je ze niet omzeilt. Markdown-pijplijnen worden sinks wanneer ze onbewerkte HTML produceren en je die HTML aan de DOM toewijst zonder te sanitizeren.
Demo-opmerking (belangrijk): In HTML5 worden <script>-knooppunten die zijn ingevoegd via innerHTML / dangerouslySetInnerHTML niet uitgevoerd—de parser voert ze niet uit zoals een klassieke gereflecteerde XSS. Om uitvoering te zien wanneer HTML wordt geïnjecteerd, vertrouwen payloads meestal op attribuut handlers (bijv. onerror op img) of vergelijkbaar. De insecure-patterns document in de demo-repo legt dit uit, zodat je de app test met voorbeelden die daadwerkelijk afgaan na invoering.
Als de UI alleen platte tekst nodig heeft — Bind tekst met textContent, React-tekstkinderen of MDX die compileert naar componenten zonder een HTML-pijplijn. Geen sanitizer vereist; je bent niet in het HTML-spel.
Als je rijkere tekst nodig hebt (bold, lijsten, links) — Je hebt ofwel een beperkte opmaaktaal die is samengesteld tot veilige elementen ofwel HTML-sanitisatie met een allowlist (tags + attributen). Codering (bijv. HTML-entiteit escaping) is voor het plaatsen van data in HTML-tekstknooppunten; sanitisatie is voor wanneer je een subset van HTML moet toestaan. Verwissel deze niet.
Defensie in diepte voor rijkere tekst in echte producten — Valideer/sanitize bij schrijven (API weigert onbekende tags, lengtebeperkingen) en sanitize of render door een veilig pad bij lezen (weergavelaag). Opslag kan worden teruggedraaid, gecorrumpeerd of geschreven door een andere servicerversie.
DOMPurify is een browser-sanitizer met een standaardprofiel; je configureert het nog steeds voor je product:
ALLOWED_TAGS / ALLOWED_ATTR — Begin minimaal (p, br, strong, em, a met alleen href als je links nodig hebt). Elke extra tag is een aanvaloppervlak.ADD_ATTR / FORBID_TAGS — Expliciet verslaat "bijna alles toestaan".RETURN_DOM / RETURN_TRUSTED_TYPE — Geef de voorkeur aan DOM-knooppunten of TrustedHTML-achtige uitvoer als je integreert met Trusted Types.afterSanitizeAttributes — Strip href-waarden die beginnen met javascript: of vreemde data: MIME-types als je links toestaat.In frontend-xss-demo op main, loopt de veilige route (/secure) todo-tekst door DOMPurify voordat dangerouslySetInnerHTML; de onveilige route (/insecure) doet dat niet—dezelfde UI, verschillend betrouwbaarheidsbeleid. Portugees /seguro en /inseguro bestaan nog steeds als legacy aliases en redirecten naar /secure en /insecure.
CSP beperkt wat kan worden uitgevoerd wanneer iets door de mazen glipt. In apps/web/next.config.ts stelt deze site een CSP in met default-src 'self', strakke object-src 'none', base-uri 'self', form-action 'self', frame-ancestors 'none', plus script-src / style-src met 'unsafe-inline' omdat Next.js App Router + MUI sx momenteel inline script/style nodig hebben in deze setup—gedocumenteerd in code. Nonce- of hash-gebaseerde script-src zou de brede inline script-toestemming verwijderen maar vereist middleware om nonces per verzoek in te injecteren—waard om te plannen als je zelden gebruikers-HTML verzendt.
Realiteitscheck: CSP vervangt geen sanitisatie voor gebruikers-HTML; het vernauwt de explosieradius (bijv. kan script-hosts blokkeren die je niet hebt toegestaan). Inline-gebeurtenishandlers (onerror, enz.) worden niet automatisch geneutraliseerd alleen omdat je een CSP instelt—'unsafe-inline' op script-src is gebruikelijk in echte apps (inclusief de Next/MUI-opstelling van deze site), en blokkeren van handlers vereist meestal expliciete script-src / script-src-attr (of nonces/hashes), afhankelijk van browser en CSP-niveau.
XSS kan CSRF-tokens omzeilen als de token leesbaar is vanuit de DOM of als het aanvaller-script verzoeken doet met referenties. Dus: geef prioriteit aan XSS-fixes; ook:
HttpOnly, Secure, SameSite=Lax of Strict waar flows het toestaat—vermindert cross-site cookie-lekkage en klassieke CSRF.SameSite-cookies, anti-CSRF-tokens of aangepaste headers + CORS-beleid zodat willekeurige sites geen POST-verzoeken met referenties kunnen doen.Frontend-taak: plaats geen geheimen in JS-leesbare opslag als het vermijdbaar is; gebruik fetch met expliciet credentials-beleid dat is afgestemd op je API-ontwerp.
apps/web/next.config.ts: beveiligingsheaders op /(.*); CSP-tekenreeks gebouwd in code met env-specifieke script-src (dev unsafe-eval voor React-stacks alleen waar nodig).apps/web/app/api/chat/route.ts: JSON-parsing, lege controle, MAX_MESSAGE_LENGTH-kap—misbruik vormgeven, geen XSS op zichzelf.apps/web/lib/blog/mdx.tsx: MDX met een vaste componentkaart (next-mdx-remote/rsc), geen onbewerkte HTML-tekenreeksen uit CMS. Verschillend dreigingsmodel dan "berichttekst met HTML".main je geeftKloon frontend-xss-demo, voer npm install en npm run dev uit → Vite serveert de app op http://localhost:5173.
/insecure met payloads uit de documenten; dezelfde stroom op /secure toont gesaniseerde uitvoer.docs/xss/: een overzicht, drie impact-walkthroughs (acties zonder gebruikersinteractie, interne phishing, sessie-kaping / opslag), plus insecure-patterns en safe-patterns voor code-level doe's en don'ts.localStorage (zie 03-session-hijacking) zodat je kunt zien wat script in de pagina kan lezen; insecure-patterns suggereert ook localStorage.getItem('auth_token') in DevTools om het te inspecteren.Vergelijk /insecure vs /secure in Elements en Console: dezelfde componenten, verschillende verwerking van de tekenreeks voordat deze de DOM bereikt.
rg "dangerouslySetInnerHTML|innerHTML|insertAdjacentHTML|eval\\(|new Function" in je app en afhankelijkheden.<img src=x onerror=...>, javascript: URLs—en onthoud innerHTML executeert geen <script> zoals veel cheat-sheets impliceren. Voor innerHTML-stijl injectiedemos, geef de voorkeur aan onerror op img (of vergelijkbaar); <svg onload> is vaak onbetrouwbaar wanneer op die manier ingevoegd.SameSite, referenties en CSRF-strategie af met backend; neem aan dat XSS en CSRF worden gekoppeld.XSS is controle-flow: data die overgaat in interpreteren. Bescherming is typen bij de grens: platte tekst, veilige gestructureerde componenten of gesaniseerde HTML met een minimale allowlist—plus CSP en cookie semantiek die beperken wat een afwijkend script nog kan doen. De demo op main maakt die grens zichtbaar: /insecure vs /secure, gedocumenteerde impacts onder docs/xss/, en discipline in elke PR die tekenreeksen nabij de DOM aanraakt.