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:
| Pattern | Risk | Action |
|---|---|---|
dangerouslySetInnerHTML | High | Verify HTML is sanitized (see step 2) |
innerHTML | High | Replace with React or sanitized dangerouslySetInnerHTML |
eval() | Critical | Remove; never execute user input as code |
Function() constructor | Critical | Remove; never create functions from user input |
element.insertAdjacentHTML() | High | Replace 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:
-
dangerouslySetInnerHTMLwithout sanitization (HIGH RISK):- Line 32:
<div dangerouslySetInnerHTML={{ __html: comment.html }} /> - Issue:
comment.htmlcomes from the API. If the backend is compromised or an attacker controls the API, malicious HTML is injected. - Fix: Add DOMPurify sanitization.
- Line 32:
-
Unvalidated
hrefattribute (MEDIUM RISK):- Line 33:
<a href={comment.authorUrl}>Profile</a> - Issue: If
comment.authorUrlisjavascript:alert(1), the attack succeeds (React blocks this, but best to be explicit). - Fix: Validate the URL before binding.
- Line 33:
-
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.
- Line 14:
-
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.
- Line 20 & 26:
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 unvalidatedhrefattributes. - 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?
- Assess severity (can it be exploited remotely? does it require authentication?).
- Create a security patch and test thoroughly.
- Deploy the patch immediately.
- Notify affected users if personal data was exposed.
- 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.