Render user text as React children and attributes, which React escapes, and keep it out of dangerouslySetInnerHTML; if a field must carry HTML, sanitize it with DOMPurify first. In JSON-LD or any inline script, replace every < with \u003c after JSON.stringify. Link a website only when URL.parse gives http or https.
- Search for dangerouslySetInnerHTML, innerHTML, outerHTML, insertAdjacentHTML, document.write and markdown or HTML strings passed to them, in server and client components alike.
- Where the value is plain text, delete the escape hatch and render it as a child ({bio}) or an attribute (alt={name}); React escapes & < > " and ' for you.
- Where you really need user HTML (rich text, rendered Markdown), sanitize it with a maintained HTML sanitizer such as DOMPurify (on the server with a DOM implementation such as jsdom) and an allowlist of tags and attributes, then pass the result to dangerouslySetInnerHTML.
- For JSON-LD and any other data you inline in a <script>, keep JSON.stringify and replace every < with \u003c, as the Next.js JSON-LD guide shows; or pass the data to a client component as props, which Next.js serializes for you.
- For links, parse the URL with URL.parse(website) and link only http and https (mailto if you need it); render text instead of a link otherwise.
- Add regression tests that render the page to HTML with this card's payloads, parse it with an HTML5 parser and assert that no script element, event-handler attribute or non-http(s) link appears.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code: rich-text bio
<img src="/avatar.svg" alt={name} width={64} height={64} />
<h1>{name}</h1>
<p className="bio" dangerouslySetInnerHTML={{ __html: bio }} />With IMPL=vulnerable, 4 of 11 tests fail (3 exploit tests).
The vulnerable code: JSON-LD
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(personJsonLd(profile)) }} />With IMPL=vulnerable, 4 of 11 tests fail (3 exploit tests).
The fix: render text as text
<img src="/avatar.svg" alt={name} width={64} height={64} />
<h1>{name}</h1>
<p className="bio">{bio}</p>With IMPL=fixed, 11 of 11 tests pass.
The fix: JSON-LD with < escaped
// JSON.stringify does not escape <, so "</script>" in any field would end the element. < is
// the same character to a JSON parser and plain text to the HTML parser.
const jsonLd = JSON.stringify(personJsonLd(profile)).replace(/</g, '\\u003c');With IMPL=fixed, 11 of 11 tests pass.
The fix: URL scheme allowlist
// Escaping cannot make a URL safe: javascript:alert(1) has nothing to escape. Parse it the way
// browsers do and link only to http and https.
const url = URL.parse(website);
const href = url && (url.protocol === 'https:' || url.protocol === 'http:') ? url.href : null;With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
// Allow formatting in the bio, but remove scripts.
const cleanBio = bio.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');With IMPL=wrong, 3 of 11 tests fail (2 exploit tests).
A fix that still fails: closing tag escaped in one letter case
const jsonLd = JSON.stringify(personJsonLd(profile)).replaceAll('</script>', '<\\/script>');With IMPL=wrong, 3 of 11 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// React 19 replaces any javascript: URL in href with this one, which only throws (checked with
// react-dom 19.3.0). It runs no attacker code, so the link check accepts it.
const REACT_BLOCKED_URL = "javascript:throw new Error('React has blocked a javascript: URL as a security precaution.')";
// What a browser would run from the page: script elements other than JSON-LD data blocks,
// event-handler attributes, and links whose URL, parsed the way browsers parse it, is not http or
// https.
function expectNothingExecutable(elements: Element[]) {
const scripts = elements.filter((e) => e.tagName === 'script' && attr(e, 'type') !== 'application/ld+json');
expect(scripts.map(text), 'script element').toEqual([]);
const handlers = elements.flatMap((e) => e.attrs.filter((a) => /^on/i.test(a.name)).map((a) => `${e.tagName} ${a.name}`));
expect(handlers, 'event-handler attribute').toEqual([]);
for (const href of links(elements)) {
if (href === REACT_BLOCKED_URL) continue;
const { protocol } = new URL(href, 'https://profiles.example/');
expect(protocol === 'https:' || protocol === 'http:', `link to a ${protocol} URL: ${JSON.stringify(href)}`).toBe(true);
}
}
describe(`exploit (IMPL=${implName})`, () => {
test('a script element in the bio does not run', async () => {
expectNothingExecutable(await profile({ name: 'Mallory', bio: '<script>alert(document.domain)</script>' }));
});
test('an event-handler attribute in the bio does not run', async () => {
expectNothingExecutable(await profile({ name: 'Mallory', bio: '<img src=x onerror=alert(document.domain)>' }));
});
test('a quote in the name cannot break out of the alt attribute', async () => {
const name = '" onerror="alert(document.domain)';
const page = await profile({ name });
expectNothingExecutable(page);
expect(avatarAlt(page)).toBe(name);
});
test('a javascript: website is not linked', async () => {
expectNothingExecutable(await profile({ name: 'Mallory', website: 'javascript:alert(document.domain)' }));
});
test('a tab inside the scheme does not smuggle a javascript: URL through', async () => {
// Browsers delete tabs and newlines from a URL before parsing it.
expectNothingExecutable(await profile({ name: 'Mallory', website: 'java\tscript:alert(document.domain)' }));
});
test('a closing script tag in the name cannot end the JSON-LD block', async () => {
// The HTML parser ends a script element at </script in any letter case.
const name = '</ScRiPt><script>alert(document.domain)</script>';
const page = await profile({ name });
expectNothingExecutable(page);
expect(jsonLd(page).name).toBe(name);
});
});- dangerouslySetInnerHTML is the React hatch: anything in __html is parsed as HTML. In a server component the HTML goes out in the first response, so an injected <script> runs when the browser parses the page, not only <img onerror> as with client-side innerHTML.
- A regular expression that removes <script> elements leaves <img onerror>, <svg onload> and every other event-handler vector. This card's wrong implementation does exactly that.
- JSON.stringify does not escape < or /, so a field containing </script> ends a JSON-LD block early and the rest of the field runs as markup. The HTML parser matches </script in any letter case, so replacing only the lower-case '</script>' (the wrong implementation) does not help.
- React 19 replaces a javascript: URL in href, src or a form action with javascript:throw new Error('React has blocked a javascript: URL as a security precaution.') (checked with react-dom 19.3.0, including mixed case, a leading space and an embedded tab), which is why the javascript: tests pass even against the vulnerable component. It does not touch data: or other schemes, nor code outside React such as location.href = website or window.open(website), so keep the scheme allowlist.
- TypeScript types do not sanitize anything: a prop typed string can still hold markup.
- A Content-Security-Policy with nonces (Next.js supports them through proxy.ts) limits the damage of a missed sink, but it is a second layer, not a substitute for encoding.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs xss/typescript-nextjs does this for you).
- Against your own app, save a profile whose bio is <img src=x onerror=alert(document.domain)> and whose name is </ScRiPt><script>alert(document.domain)</script>, load the page with JavaScript on, and check that both show as text and that the JSON-LD still parses.
- Grep for dangerouslySetInnerHTML: Semgrep's React rule flags every use, including safe ones, and CodeQL's sink has no default source in an App Router page, so review each hit and keep the regression tests.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | typescript.react.security.audit.react-dangerouslysetinnerhtml.react-dangerouslysetinnerhtml | Flagged both dangerouslySetInnerHTML props in lib/impl/vulnerable.tsx (bio and JSON-LD), both in wrong.tsx, and the JSON-LD prop in fixed.tsx. It is a taint rule from a component's parameters to __html; the .replace(/</g, '\u003c') that makes the fixed JSON-LD safe is not a sanitizer it knows, so that hit is a false positive to review. It is the useful signal for this stack: every hit is a place to check.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/react app lib (4 rules) |
| Semgrep | p/owasp-top-ten, p/javascript, p/typescript, p/security-audit | No findings. None of these packs flagged the unchecked href or the wrong implementation's regular-expression sanitizer.Checked: Semgrep 1.180.0 (CE); on app and lib |
| CodeQL | js/xss | The sink is modelled: DomBasedXssCustomizations.qll treats the __html of a dangerouslySetInnerHTML value as an XSS sink. The source is not, by default: App Router page searchParams are not a modelled remote source, and React props count only under the view-component-input threat model, which is not in the default set (threat-model-grouping.model.yml). No alert is expected with default settings.Checked: github/codeql main at b5b8165, library source read; not executed |
| Component | Version |
|---|---|
| Language | TypeScript (TSX) |
| Runtime | Node.js 26.11.1 |
| Framework | Next.js 16.4.0 (App Router server component) with React 19.3.0 |
| next | 16.4.0 |
| react, react-dom (react-dom/server renders the page in the tests) | 19.3.0 |
| parse5 (test only) | 8.0.1 |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the xss/typescript-nextjsfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs xss/typescript-nextjs runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- next 16.4.0
- react 19.3.0
- parse5 8.0.1
- vitest 5.0.3 (Vite 8.3.4)
- typescript 6.0.3
- semgrep 1.180.0
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 4 of 11 tests fail (3 exploit tests); wrong: 3 of 11 tests fail (2 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 4/11 fail (3 exploit tests: script, onerror, JSON-LD breakout); wrong 3/11 fail (2 exploit tests: onerror, JSON-LD breakout), offline after npm ci. Both broken implementations also fail the functional test that shows a bio with tags as typed.
- Dependency freshness
- next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, parse5 8.0.1 on 2026-04-19, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (7.0.2 no longer exports the compiler API next build loads). npm audit found 0 vulnerabilities. tsc --noEmit passes. The tests render the page with react-dom/server's renderToString, which produces the same markup for these elements as Next.js's server rendering, and parse it with parse5. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP Cross Site Scripting Prevention Cheat Sheet
- React: dangerously setting the inner HTML
- React source: sanitizeURL (the javascript: URL replacement)
- Next.js: JSON-LD guide (replace < with \u003c)
- HTML Standard: restrictions for contents of script elements
- DOMPurify
- MDN: URL.parse()
- Next.js: Content Security Policy guide (nonces)
- CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Attack patterns: