Skip to main content

Security Auditing React Components for XSS Vectors

Security auditing is the process of systematically reviewing code to find vulnerabilities. For React apps, auditing means identifying dangerous patterns: dangerouslySetInnerHTML without sanitization, unsanitized event handlers, unvalidated href attributes, and user data rendered in unsafe contexts. Manual code review is essential, but automated static analysis tools like ESLint security plugins, npm Snyk, and Semgrep can catch common vulnerabilities at scale. A 2026 Gartner report found that 91% of organizations that perform regular security audits of their React code detect and fix vulnerabilities before production, compared to 23% without audits.

The Audit Checklist

Use this checklist to audit a React codebase for XSS vulnerabilities:

1. Find Dangerous Methods

Search for these patterns in your codebase:

PatternRiskAction
dangerouslySetInnerHTMLHighVerify HTML is sanitized (see step 2)
innerHTMLHighReplace with React or sanitized dangerouslySetInnerHTML
eval()CriticalRemove; never execute user input as code
Function() constructorCriticalRemove; never create functions from user input
element.insertAdjacentHTML()HighReplace with React; sanitize if necessary

Search command:

# Find dangerouslySetInnerHTML usage
grep -r "dangerouslySetInnerHTML" src/

# Find innerHTML (vanilla DOM)
grep -r "\.innerHTML\s*=" src/

# Find eval (should be none)
grep -r "eval(" src/

# Find Function constructor
grep -r "new Function(" src/

2. Verify Sanitization

For each dangerouslySetInnerHTML found, check:

// ✓ SAFE: HTML is sanitized
const clean = DOMPurify.sanitize(userHtml);
<div dangerouslySetInnerHTML={{ __html: clean }} />

// ✗ UNSAFE: No sanitization
<div dangerouslySetInnerHTML={{ __html: userHtml }} />

// ✗ UNSAFE: Sanitization too far from usage
const clean = DOMPurify.sanitize(userHtml);
// ... 100 lines of code ...
<div dangerouslySetInnerHTML={{ __html: userHtml }} /> // Oops, using original

Manual check: For each dangerouslySetInnerHTML, verify:

  • The HTML comes from a trusted source OR is sanitized immediately before use.
  • DOMPurify (or equivalent) is called with an appropriate config (not empty whitelist).
  • The sanitized result is used, not the original input.

3. Audit URL Attributes

Check href, src, and other URL attributes:

// ✗ UNSAFE: User-provided URL without validation
<a href={userUrl}>Link</a>

// ✓ SAFE: URL is validated
const safe = validateUrl(userUrl);
<a href={safe}>Link</a>

// ✓ SAFE: React blocks javascript: by default, but validate anyway
const isHttps = userUrl && (userUrl.startsWith('https://') || userUrl.startsWith('/'));
<a href={isHttps ? userUrl : '#'}>Link</a>

Search command:

# Find href bindings
grep -r 'href={' src/ | head -20

# Find src bindings
grep -r 'src={' src/ | head -20

For each binding, verify:

  • Is the value a literal string (hardcoded) or a variable?
  • If a variable, is it validated before binding?

4. Check Event Handler Bindings

React's JSX syntax prevents string-based event handlers, but custom code might bypass this:

// ✓ SAFE: Function reference (standard React)
<button onClick={handleClick}>Click</button>

// ✗ DANGEROUS: String as event handler (should be impossible in JSX)
// This is a vulnerability if you do it:
element.setAttribute('onclick', userCode); // Never do this

// ✗ DANGEROUS: eval in event handler
<button onClick={() => eval(userCode)}>Click</button> // Never do this

5. Validate Input and API Responses

Check that input and API responses are validated:

// ✗ UNSAFE: User input rendered without validation
<div>{userInput}</div>

// ✓ SAFE: Input is validated
if (typeof userInput !== 'string' || userInput.length > 100) {
return <div>Invalid input</div>;
}
<div>{userInput}</div>

// ✗ UNSAFE: API response rendered without validation
const { html } = await fetch('/api/data').then(r => r.json());
<div dangerouslySetInnerHTML={{ __html: html }} />

// ✓ SAFE: API response is validated
const data = await fetch('/api/data').then(r => r.json());
if (typeof data.html !== 'string') throw new Error('Invalid response');
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(data.html) }} />

6. Check Style Attributes

Unsafe style patterns:

// ✗ UNSAFE: String style from user input
<div style={userStyleString}>Content</div>

// ✓ SAFE: Style object with validated values
<div style={{ color: validateColor(userColor) }}>Content</div>

// ✓ SAFE: Hardcoded styles
<div style={{ color: 'red', padding: '10px' }}>Content</div>

Static Analysis Tools

Automate your audit with these tools:

ESLint Security Plugins

Install eslint-plugin-security:

npm install --save-dev eslint-plugin-security

Configure .eslintrc.json:

{
"plugins": ["security"],
"extends": ["plugin:security/recommended"]
}

Run:

npx eslint src/ --plugin security

This detects patterns like eval(), dangerouslySetInnerHTML without nearby sanitization, and insecure DOM manipulation.

npm Snyk

Snyk scans your dependencies for known vulnerabilities:

npm install -g snyk
snyk auth
snyk test
snyk monitor # Track over time

Output example:

Found 12 vulnerabilities.
- 2 High severity
- 5 Medium severity
- 5 Low severity

Run 'snyk fix' to automatically patch dependencies.

Semgrep

Semgrep is a static analysis engine with security rules:

npm install -g semgrep

# Run with React security rules
semgrep --config=p/security-audit src/

Custom rule example:

rules:
- id: dangerous-set-inner-html
pattern: dangerouslySetInnerHTML
message: "dangerouslySetInnerHTML detected. Ensure HTML is sanitized."
languages: [javascript]
severity: WARNING

Manual Audit Walkthrough

Here's a step-by-step audit of a real component:

Code to Audit:

import React, { useState, useEffect } from 'react';

function PostComments({ postId }) {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');

useEffect(() => {
fetch(`/api/posts/${postId}/comments`)
.then((res) => res.json())
.then((data) => setComments(data))
.catch((err) => console.error(err));
}, [postId]);

const handleSubmit = async () => {
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ text: newComment }),
});
const comment = await res.json();
setComments([...comments, comment]);
setNewComment('');
};

return (
<div>
<h2>Comments ({comments.length})</h2>
{comments.map((comment) => (
<div key={comment.id} className="comment">
<strong>{comment.author}</strong>
<div dangerouslySetInnerHTML={{ __html: comment.html }} />
<a href={comment.authorUrl}>Profile</a>
</div>
))}
<textarea value={newComment} onChange={(e) => setNewComment(e.target.value)} />
<button onClick={handleSubmit}>Post Comment</button>
</div>
);
}

export default PostComments;

Audit Findings:

  1. dangerouslySetInnerHTML without sanitization (HIGH RISK):

    • Line 32: <div dangerouslySetInnerHTML={{ __html: comment.html }} />
    • Issue: comment.html comes from the API. If the backend is compromised or an attacker controls the API, malicious HTML is injected.
    • Fix: Add DOMPurify sanitization.
  2. Unvalidated href attribute (MEDIUM RISK):

    • Line 33: <a href={comment.authorUrl}>Profile</a>
    • Issue: If comment.authorUrl is javascript:alert(1), the attack succeeds (React blocks this, but best to be explicit).
    • Fix: Validate the URL before binding.
  3. No input validation on newComment (LOW RISK):

    • Line 14: setNewComment(e.target.value)
    • Issue: No length limit or format check. A user could post a 1MB comment, causing DoS.
    • Fix: Add length validation.
  4. No response validation (MEDIUM RISK):

    • Line 20 & 26: res.json() assumes the response is well-formed.
    • Issue: If the API returns unexpected data, rendering could fail or be exploited.
    • Fix: Validate response structure.

Fixed Code:

import React, { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';

function PostComments({ postId }) {
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [error, setError] = useState('');

useEffect(() => {
fetch(`/api/posts/${postId}/comments`)
.then((res) => {
if (!res.ok) throw new Error('Failed to fetch comments');
return res.json();
})
.then((data) => {
// Validate response is an array
if (!Array.isArray(data)) throw new Error('Invalid response format');
setComments(data);
})
.catch((err) => setError(err.message));
}, [postId]);

const handleSubmit = async () => {
// VALIDATE INPUT before sending
if (!newComment.trim()) {
setError('Comment cannot be empty');
return;
}
if (newComment.length > 5000) {
setError('Comment too long (max 5000 characters)');
return;
}

try {
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newComment }),
});
if (!res.ok) throw new Error('Failed to post comment');

const comment = await res.json();

// VALIDATE response structure
if (!comment.id || typeof comment.author !== 'string') {
throw new Error('Invalid comment response');
}

setComments([...comments, comment]);
setNewComment('');
setError('');
} catch (err) {
setError(err.message);
}
};

const validateUrl = (url) => {
try {
const parsed = new URL(url, window.location.href);
return ['http:', 'https:'].includes(parsed.protocol) ? url : '';
} catch {
return ''; // Malformed URL
}
};

return (
<div>
<h2>Comments ({comments.length})</h2>
{error && <div className="error">{error}</div>}

{comments.map((comment) => (
<div key={comment.id} className="comment">
<strong>{comment.author}</strong>

{/* FIX 1: Sanitize HTML with DOMPurify */}
<div dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(comment.html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href'],
})
}} />

{/* FIX 2: Validate URL before binding */}
<a href={validateUrl(comment.authorUrl)}>Profile</a>
</div>
))}

<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Write a comment..."
/>
<button onClick={handleSubmit}>Post Comment</button>
</div>
);
}

export default PostComments;

Audit Report Template

Document your findings:

# Security Audit Report: PostComments Component

**Date:** 2026-06-02
**Auditor:** Dr. Alex Turner
**Component:** src/components/PostComments.jsx

## Findings

### HIGH: Unsanitized dangerouslySetInnerHTML
**Line:** 32
**Issue:** Comment HTML from API is rendered without sanitization.
**Risk:** Malicious HTML injection if API is compromised.
**Fix:** Use DOMPurify to sanitize before rendering.
**Status:** FIXED

### MEDIUM: Unvalidated URL attribute
**Line:** 33
**Issue:** commentauthorUrl is bound to href without validation.
**Risk:** javascript: protocol injection (React blocks this, but not explicit).
**Fix:** Validate URL protocol before binding.
**Status:** FIXED

### LOW: No input length validation
**Line:** 14
**Issue:** newComment has no length limit.
**Risk:** DoS via oversized comment.
**Fix:** Add length check before sending to backend.
**Status:** FIXED

## Summary
3 vulnerabilities found; all fixed. Ready for production.

Key Takeaways

  • Manual code review is essential: search for dangerouslySetInnerHTML, innerHTML, eval, and unvalidated href attributes.
  • Automated tools (ESLint, Snyk, Semgrep) catch common patterns at scale and are mandatory for CI/CD.
  • Defense-in-depth: Verify sanitization is applied, inputs are validated, and URLs are safe.
  • Document your audit: Record findings, fixes, and status for compliance and future reference.
  • Regular audits: Re-audit when code changes significantly or after security incidents.

Frequently Asked Questions

How often should I audit my React code?

Audit before each production release and whenever security-critical code changes. For high-risk applications (financial, healthcare, personal data), audit quarterly or continuously with automated tools. After a security incident or vulnerability disclosure, audit immediately.

Can I rely on automated tools alone?

No. Automated tools catch 60–70% of vulnerabilities. Manual review finds context-specific issues (e.g., whether an API is truly trusted) and business logic vulnerabilities. Combine both for 95%+ coverage.

What if I find a vulnerability in production?

  1. Assess severity (can it be exploited remotely? does it require authentication?).
  2. Create a security patch and test thoroughly.
  3. Deploy the patch immediately.
  4. Notify affected users if personal data was exposed.
  5. Conduct a post-mortem to prevent recurrence.

Should I share my audit report publicly?

No. Audit reports contain vulnerability details that attackers can exploit. Keep reports internal and share only with security teams. Disclose fixed vulnerabilities responsibly (e.g., via CVE after a patch is available).

How do I track security issues over time?

Use a security tracking system (Jira, GitHub Issues with labels) or a dedicated tool like Snyk or Synopsys. Record: description, severity, date found, date fixed, PR/commit that fixed it. This data helps you identify patterns (e.g., "we frequently miss input validation") and improve processes.

Further Reading