Test for reflected and stored XSS, validate CSP headers, verify DOMPurify sanitization, and detect dangerous innerHTML patterns with Semgrep in React applications.
Cross-Site Scripting (XSS) allows attackers to inject malicious JavaScript into pages viewed by other users. In a React application, XSS is prevented by React's automatic HTML escaping — but gaps exist: dangerouslySetInnerHTML, innerHTML in useEffect, and markdown rendering libraries producing unsanitized HTML.
This lesson covers all three XSS types, automated detection, and verifying CSP headers provide defense-in-depth.
React prevents most XSS because JSX expressions are escaped by default. The gaps are:
1// SAFE: React escapes this automatically
2return <div>{userContent}</div>;
3// Renders: <script>alert(1)</script> — harmless
4
5// DANGEROUS: bypasses React escaping
6return <div dangerouslySetInnerHTML={{ __html: userContent }} />;
7// Renders actual <script> tag — XSS!
8
9// SAFE: sanitize before dangerouslySetInnerHTML
10import DOMPurify from 'dompurify';
11return <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userContent) }} />;
12
13// DANGEROUS: DOM manipulation in useEffect
14useEffect(() => {
15 document.getElementById('bio').innerHTML = userBio; // XSS!
16}, [userBio]);
17
18// SAFE: use textContent for plain text
19useEffect(() => {
20 document.getElementById('bio').textContent = userBio;
21}, [userBio]);Test your sanitization function against all common XSS payload categories:
1// apps/web/src/lib/__tests__/sanitize.test.ts
2import { describe, it, expect } from 'vitest';
3import { sanitizeHtml } from '../sanitize';
4
5const XSS_PAYLOADS = [
6 '<script>alert(1)</script>',
7 '<img src=x onerror=alert(1)>',
8 '<svg onload=alert(1)>',
9 'javascript:alert(1)',
10 '<a href="javascript:alert(1)">click</a>',
11 '<div onclick="alert(1)">click</div>',
12 '<iframe src="javascript:alert(1)"></iframe>',
13 '<object data="data:text/html,<script>alert(1)</script>">',
14 '<input onfocus=alert(1) autofocus>',
15];
16
17describe('sanitizeHtml', () => {
18 XSS_PAYLOADS.forEach((payload) => {
19 it(`removes: ${payload.substring(0, 40)}`, () => {
20 const result = sanitizeHtml(payload);
21 expect(result).not.toContain('<script');
22 expect(result).not.toContain('javascript:');
23 expect(result.toLowerCase()).not.toMatch(/on\w+=/); // event handlers
24 expect(result).not.toContain('<iframe');
25 expect(result).not.toContain('<object');
26 });
27 });
28
29 it('preserves safe HTML', () => {
30 const safe = '<p>Hello <strong>world</strong></p>';
31 expect(sanitizeHtml(safe)).toBe(safe);
32 });
33
34 it('sanitize implementation using DOMPurify', () => {
35 // Your sanitize.ts should look like:
36 // import DOMPurify from 'isomorphic-dompurify';
37 // export function sanitizeHtml(html: string): string {
38 // return DOMPurify.sanitize(html, {
39 // ALLOWED_TAGS: ['p','strong','em','a','code','pre','ul','ol','li','h2','h3'],
40 // ALLOWED_ATTR: ['href','class'],
41 // FORBID_SCRIPTS: true,
42 // });
43 // }
44 expect(sanitizeHtml('<script>alert(1)</script><p>Safe</p>')).toBe('<p>Safe</p>');
45 });
46});CSP restricts which scripts can execute — defense-in-depth even if sanitization fails.
1// Test CSP header exists and has required directives
2describe('Content Security Policy headers', () => {
3 it('sets Content-Security-Policy on all responses', async () => {
4 const res = await request(app.getHttpServer()).get('/api/health');
5 expect(res.headers['content-security-policy']).toBeDefined();
6 });
7
8 it('CSP blocks inline scripts (no unsafe-inline for script-src)', async () => {
9 const res = await request(app.getHttpServer()).get('/api/health');
10 const csp = res.headers['content-security-policy'];
11 // unsafe-inline in script-src defeats the purpose of CSP
12 expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
13 });
14
15 it('CSP restricts default-src to self', async () => {
16 const res = await request(app.getHttpServer()).get('/api/health');
17 expect(res.headers['content-security-policy']).toMatch(/default-src\s+'self'/);
18 });
19});
20
21// next.config.ts — production CSP
22const CSP = [
23 "default-src 'self'",
24 "script-src 'self' 'nonce-NONCE'",
25 "style-src 'self' 'unsafe-inline'",
26 "img-src 'self' data: https:",
27 "frame-src 'none'",
28 "object-src 'none'",
29].join('; ');Use Semgrep to detect dangerouslySetInnerHTML without sanitization at CI time.
1# .semgrep/xss-rules.yml
2rules:
3 - id: dangerous-inner-html-no-sanitize
4 patterns:
5 - pattern: dangerouslySetInnerHTML={{ __html: $EXPR }}
6 - pattern-not: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize($EXPR) }}
7 - pattern-not: dangerouslySetInnerHTML={{ __html: sanitizeHtml($EXPR) }}
8 message: dangerouslySetInnerHTML without sanitization — wrap with DOMPurify.sanitize()
9 languages: [tsx, jsx]
10 severity: ERROR
11
12 - id: innerhtml-assignment
13 pattern: $EL.innerHTML = $EXPR
14 message: Direct innerHTML assignment — use textContent or DOMPurify.sanitize()
15 languages: [javascript, typescript]
16 severity: WARNING1# Run the custom XSS rules
2semgrep --config=.semgrep/xss-rules.yml apps/web/src/
3
4# Run the OWASP ruleset
5semgrep --config=p/owasp-top-ten apps/Markdown libraries producing HTML are a common XSS vector.
1// apps/web/src/lib/__tests__/markdown.test.ts
2import { renderMarkdown } from '../markdown';
3
4describe('Markdown renderer XSS prevention', () => {
5 it('does not execute script tags in code blocks', () => {
6 const md = '```html\n<script>alert(1)</script>\n```';
7 const html = renderMarkdown(md);
8 expect(html).not.toContain('<script>alert');
9 expect(html).toContain('<script>'); // escaped
10 });
11
12 it('removes javascript: links', () => {
13 const md = '[click me](javascript:alert(1))';
14 expect(renderMarkdown(md)).not.toContain('javascript:');
15 });
16
17 it('strips raw HTML script tags', () => {
18 const md = '<script>alert(1)</script>\nNormal text';
19 expect(renderMarkdown(md)).not.toContain('<script>');
20 });
21});
22
23// Safe markdown pipeline:
24// import { marked } from 'marked';
25// import DOMPurify from 'isomorphic-dompurify';
26// export function renderMarkdown(md: string): string {
27// const rawHtml = marked.parse(md);
28// return DOMPurify.sanitize(rawHtml, {
29// ALLOWED_TAGS: ['p','strong','em','a','code','pre','ul','ol','li','h2','h3'],
30// ALLOWED_ATTR: ['href', 'class'],
31// });
32// }Combined Vitest unit tests and Jest API tests covering DOMPurify sanitization, CSP headers, and Semgrep integration.
1// apps/web/src/lib/__tests__/xss-prevention.spec.ts
2import { describe, it, expect } from 'vitest';
3import DOMPurify from 'isomorphic-dompurify';
4
5function sanitize(html: string): string {
6 return DOMPurify.sanitize(html, {
7 ALLOWED_TAGS: ['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'h2', 'h3'],
8 ALLOWED_ATTR: ['href', 'class'],
9 ALLOW_DATA_ATTR: false,
10 FORBID_SCRIPTS: true,
11 });
12}
13
14const ATTACK_VECTORS = [
15 { name: 'basic script tag', payload: '<script>alert(1)</script>' },
16 { name: 'img onerror', payload: '<img src=x onerror=alert(1)>' },
17 { name: 'svg onload', payload: '<svg onload=alert(1)>' },
18 { name: 'iframe src', payload: '<iframe src="javascript:alert(1)"></iframe>' },
19 { name: 'a href javascript', payload: '<a href="javascript:alert(1)">click</a>' },
20 { name: 'onclick handler', payload: '<div onclick="fetch(atob(x))">' },
21 { name: 'data: URI', payload: '<object data="data:text/html,<script>alert(1)</script>">' },
22 { name: 'input onfocus', payload: '<input onfocus=alert(1) autofocus>' },
23 { name: 'nested bypass', payload: '<<SCRIPT>alert("XSS");//<</SCRIPT>' },
24];
25
26describe('DOMPurify sanitization', () => {
27 ATTACK_VECTORS.forEach(({ name, payload }) => {
28 it('blocks: ' + name, () => {
29 const clean = sanitize(payload);
30 expect(clean.toLowerCase()).not.toContain('<script');
31 expect(clean.toLowerCase()).not.toContain('javascript:');
32 expect(clean.toLowerCase()).not.toMatch(/on\w+=/); // event handlers
33 expect(clean.toLowerCase()).not.toContain('<iframe');
34 expect(clean.toLowerCase()).not.toContain('<object');
35 });
36 });
37
38 it('preserves course content structure', () => {
39 const course = '<h2>Intro</h2><p><strong>TypeScript</strong> fundamentals.</p>';
40 expect(sanitize(course)).toContain('<h2>');
41 expect(sanitize(course)).toContain('<strong>');
42 });
43
44 it('allows safe external links', () => {
45 const link = '<a href="https://example.com">docs</a>';
46 expect(sanitize(link)).toContain('href="https://example.com"');
47 });
48
49 it('strips href javascript: from anchor tags', () => {
50 const result = sanitize('<a href="javascript:alert(1)">click</a>');
51 expect(result).not.toContain('javascript:');
52 });
53});This test suite covers 9 XSS attack vectors against the DOMPurify sanitization function. Each test verifies the sanitized output contains no executable script references, event handlers, or dangerous data: URIs. The preservation test verifies that legitimate course content (h2, strong) survives sanitization. Run with: pnpm vitest apps/web/src/lib/__tests__/xss-prevention.spec.ts
The next lesson covers authentication testing — testing JWT token validation, session fixation, brute force protection, and MFA bypass vulnerabilities.
Your progress is saved in this browser, on this device only.