Escaping User Input in React and Vue Components
React and Vue abstract DOM manipulation behind a virtual layer, but that abstraction creates a false sense of security: each framework’s auto-escaping is context-dependent and silently fails under specific injection vectors. An attacker who can reach a dangerouslySetInnerHTML prop or a v-html binding with unsanitized input achieves the same stored or reflected XSS as if no framework were present at all.
This guide is part of Cross-Site Scripting (XSS) Mitigation, which sits within the Vulnerability Patterns & Web Mitigation Strategies pillar. For coverage of the complementary DOM-level attack surface see DOM-Based Vulnerability Sanitization.
Prerequisites
- Node.js 18+ with React 18+ or Vue 3+ project configured
dompurifyand@types/dompurifyinstalled (npm install dompurify)- TypeScript 5+ (examples use strict mode; adapt for plain JS as needed)
- ESLint with
eslint-plugin-reactoreslint-plugin-vueinstalled - Working knowledge of JSX/SFC template syntax and how browser HTML parsing works
Expected Outcomes
- All
dangerouslySetInnerHTMLcalls guarded by aSanitizedHTMLwrapper enforcing a DOMPurify allowlist - All
v-htmlusages replaced by av-sanitizecustom directive with the same allowlist - Dynamic
href/srcbindings validated against an explicit protocol allowlist - ESLint and pre-commit hooks blocking any new unsanitized sink introductions
- Passing semgrep scan confirming no raw sinks remain in the codebase
Step 1: Classify DOM Sinks and Map Untrusted Data Flows
Before writing any sanitization code, you need a sink inventory. Rendering contexts differ in how browsers parse injected content, so the required mitigation differs by context.
Build a sink registry as part of your threat modeling process. Tag every prop and state variable with a trustLevel enum — UNTRUSTED, SANITIZED, or INTERNAL — and document which rendering context each flows into. This inventory becomes your audit trail for SOC 2 evidence.
// trust-registry.ts — document each data flow
export type TrustLevel = 'UNTRUSTED' | 'SANITIZED' | 'INTERNAL';
export interface DataFlowEntry {
source: string;
prop: string;
sinkType: 'text' | 'attribute' | 'html' | 'event' | 'style';
trustLevel: TrustLevel;
sanitizer?: string;
}
export const DATA_FLOW_REGISTRY: DataFlowEntry[] = [
{ source: 'api:GET /comments', prop: 'comment.body', sinkType: 'html', trustLevel: 'UNTRUSTED', sanitizer: 'SanitizedHTML' },
{ source: 'url:?redirect=', prop: 'redirectUrl', sinkType: 'attribute', trustLevel: 'UNTRUSTED', sanitizer: 'validateProtocol' },
{ source: 'internal:auth', prop: 'user.role', sinkType: 'text', trustLevel: 'INTERNAL' },
];
Step 2: Implement DOMPurify Sanitization Wrappers for React
React’s JSX compiler escapes string interpolation by converting <, >, &, ", and ' to HTML entities — but only in text nodes. Attribute bindings pass through the browser’s attribute parser directly, allowing javascript: and data: URI execution. dangerouslySetInnerHTML disables the compiler’s escaping layer entirely.
The mitigation is a SanitizedHTML component that centralises allowlist configuration and memoises sanitization to avoid per-render DOMPurify overhead:
// components/SanitizedHTML.tsx
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const BASE_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote'],
ALLOWED_ATTR: ['href', 'class', 'rel', 'target', 'title'],
ALLOW_DATA_ATTR: false,
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange', 'onsubmit'],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
};
interface SanitizedHTMLProps {
html: string;
className?: string;
allowedTags?: string[];
allowedAttrs?: string[];
}
export const SanitizedHTML: React.FC<SanitizedHTMLProps> = ({
html,
className,
allowedTags = BASE_CONFIG.ALLOWED_TAGS as string[],
allowedAttrs = BASE_CONFIG.ALLOWED_ATTR as string[],
}) => {
const sanitized = useMemo(() => {
if (!html) return '';
return DOMPurify.sanitize(html, {
...BASE_CONFIG,
ALLOWED_TAGS: allowedTags,
ALLOWED_ATTR: allowedAttrs,
});
}, [html, allowedTags, allowedAttrs]);
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitized }} />;
};
Why RETURN_DOM: false: older DOMPurify versions are susceptible to prototype pollution when returning DOM objects. Setting both RETURN_DOM and RETURN_DOM_FRAGMENT to false forces string output and eliminates that attack surface.
For href and src attribute bindings, add explicit protocol validation before assigning the value:
// utils/url-safety.ts
export const validateProtocol = (
url: string,
allowed: string[] = ['http:', 'https:', 'mailto:', 'tel:']
): boolean => {
try {
const parsed = new URL(url, window.location.origin);
return allowed.includes(parsed.protocol.toLowerCase());
} catch {
return false; // reject malformed or opaque URIs
}
};
// Usage in JSX
const SafeLink: React.FC<{ href: string; children: React.ReactNode }> = ({ href, children }) =>
validateProtocol(href) ? <a href={href} rel="noopener noreferrer">{children}</a> : <span>{children}</span>;
//evil.com is a protocol-relative URL that inherits the current page scheme — always resolve against window.location.origin and enforce explicit schemes.
Step 3: Author a Vue Custom Directive with Runtime Sanitization
Vue’s template compiler auto-escapes {{ }} interpolations identically to React text nodes. The v-html directive bypasses compilation and injects raw HTML directly into the element’s innerHTML. Vue’s reactivity system tracks mutations but performs no sanitization — untrusted data passed to v-html executes immediately on mount or update.
Replace v-html site-wide with a v-sanitize custom directive. Registering it globally means any accidental v-html usage becomes a lint error that ESLint catches before it ships:
// directives/sanitize.ts
import DOMPurify from 'dompurify';
import type { DirectiveBinding, App } from 'vue';
const SANITIZE_CONFIG: DOMPurify.Config = {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'blockquote', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'rel', 'target', 'class'],
FORBID_TAGS: ['script', 'style', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
RETURN_DOM: false,
RETURN_DOM_FRAGMENT: false,
};
const sanitizeDirective = {
mounted(el: HTMLElement, binding: DirectiveBinding<string>): void {
if (typeof window === 'undefined') return; // SSR guard
el.innerHTML = DOMPurify.sanitize(binding.value ?? '', SANITIZE_CONFIG);
},
updated(el: HTMLElement, binding: DirectiveBinding<string>): void {
if (typeof window === 'undefined') return;
if (binding.value !== binding.oldValue) {
el.innerHTML = DOMPurify.sanitize(binding.value ?? '', SANITIZE_CONFIG);
}
},
};
export function registerSanitizeDirective(app: App): void {
app.directive('sanitize', sanitizeDirective);
}
Register during app bootstrap and use in templates:
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { registerSanitizeDirective } from './directives/sanitize';
const app = createApp(App);
registerSanitizeDirective(app);
app.mount('#app');
The updated hook includes a binding.value !== binding.oldValue guard to skip redundant sanitization passes on unrelated reactive updates, keeping performance acceptable in list-heavy views.
SSR and hydration: the typeof window === 'undefined' guard prevents DOMPurify from running server-side where the DOM API is unavailable. On the client, Vue’s hydration reuses the server-rendered HTML and then the mounted hook re-sanitizes it — a deliberate double-pass that ensures any server-side injection is stripped before the client trusts it.
Step 4: Enforce Sanitization Policy at the CI Gate
Static analysis catches unsanitized sink introductions before they reach production. Configure ESLint to error on dangerouslySetInnerHTML without an adjacent sanitizer import, and on bare v-html directives:
{
"plugins": ["security", "react", "vue"],
"rules": {
"react/no-danger": "error",
"vue/no-v-html": "error",
"no-eval": "error",
"no-implied-eval": "error",
"security/detect-object-injection": "warn"
},
"overrides": [
{
"files": ["**/SanitizedHTML.tsx"],
"rules": { "react/no-danger": "off" }
}
]
}
The override disables react/no-danger only for the wrapper component that is explicitly reviewed and allowlisted — every other file in the project remains blocked.
Wire the lint step into a pre-commit hook using husky and lint-staged:
{
"lint-staged": {
"*.{js,jsx,ts,tsx,vue}": [
"eslint --max-warnings=0",
"prettier --write"
]
}
}
For deeper coverage, add a semgrep rule that flags dangerouslySetInnerHTML and v-html regardless of import context — useful for catching patterns ESLint misses through dynamic construction:
# semgrep/xss-sinks.yml
rules:
- id: react-dangerous-inner-html
patterns:
- pattern: dangerouslySetInnerHTML={{ __html: $X }}
- pattern-not: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(...) }}
message: "dangerouslySetInnerHTML used without DOMPurify — route through SanitizedHTML component"
languages: [typescript, javascript]
severity: ERROR
- id: vue-v-html-bare
pattern: v-html="$X"
message: "v-html used directly — replace with v-sanitize directive"
languages: [vue]
severity: ERROR
Add both ESLint and semgrep steps to your CI pipeline:
# .github/workflows/security.yml
name: Security Gates
on: [push, pull_request]
jobs:
xss-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npx eslint . --max-warnings=0
- uses: returntocorp/semgrep-action@v1
with:
config: semgrep/xss-sinks.yml
Verification
After deploying the sanitization wrappers and directive, confirm each layer is working:
Unit test — SanitizedHTML strips disallowed tags:
// SanitizedHTML.test.tsx
import { render, screen } from '@testing-library/react';
import { SanitizedHTML } from '../components/SanitizedHTML';
test('strips script tags from rendered output', () => {
render(<SanitizedHTML html='<p>Safe</p><script>alert(1)</script>' />);
expect(document.querySelector('script')).toBeNull();
expect(screen.getByText('Safe')).toBeInTheDocument();
});
test('removes javascript: href', () => {
render(<SanitizedHTML html='<a href="javascript:alert(1)">click</a>' />);
const link = document.querySelector('a');
expect(link?.getAttribute('href')).toBeNull();
});
test('preserves allowed markup', () => {
render(<SanitizedHTML html='<strong>Bold</strong> and <em>italic</em>' />);
expect(document.querySelector('strong')).not.toBeNull();
expect(document.querySelector('em')).not.toBeNull();
});
Vue directive integration test:
// sanitize.directive.test.ts
import { mount } from '@vue/test-utils';
import { defineComponent } from 'vue';
import { registerSanitizeDirective } from '../directives/sanitize';
const TestComponent = defineComponent({
props: { html: String },
template: '<div v-sanitize="html" />',
});
test('v-sanitize strips inline event handlers', () => {
const app = { directive: vi.fn() } as any;
registerSanitizeDirective(app);
const wrapper = mount(TestComponent, {
props: { html: '<p onmouseover="steal()">text</p>' },
global: { directives: { sanitize: (app.directive as any).mock.calls[0][1] } },
});
expect(wrapper.html()).not.toContain('onmouseover');
});
Semgrep scan — confirm zero findings:
semgrep --config semgrep/xss-sinks.yml src/ --error
# Exit code 0 = no unsanitized sinks found
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Allowed HTML tags stripped unexpectedly | ALLOWED_TAGS array missing the tag |
Add the tag to BASE_CONFIG.ALLOWED_TAGS; document why in a code comment |
href links removed even for https: URLs |
DOMPurify stripping href when ALLOWED_ATTR does not include it |
Add 'href' to ALLOWED_ATTR; verify with DOMPurify.sanitize('<a href="https://x.com">x</a>', config) in the browser console |
Vue v-sanitize not firing on reactive updates |
updated hook guard skipping when binding.value === binding.oldValue but the source object mutated |
Ensure reactive data triggers a new string reference, not a mutation of the same string |
SSR hydration mismatch after adding v-sanitize |
Server renders raw HTML; client immediately re-sanitizes, producing different DOM | Add the typeof window === 'undefined' guard so the server skips the directive; the client re-sanitizes on mounted |
ESLint react/no-danger fires inside SanitizedHTML.tsx |
Override scope too broad | Scope the off override to the exact file path using a files glob matching only the wrapper component |
semgrep flags a false positive on a test fixture |
Test file intentionally contains unsafe patterns | Add # nosemgrep: react-dangerous-inner-html comment on the specific line and track the suppression in your audit log |
Related
- Cross-Site Scripting (XSS) Mitigation — parent cluster covering reflected, stored, and DOM-based XSS defences
- DOM-Based Vulnerability Sanitization — deeper treatment of sink-level sanitization patterns outside frameworks
- Implementing the Double-Submit Cookie Pattern in Node.js — companion guide for CSRF defence in the same request pipeline
- Vulnerability Patterns & Web Mitigation Strategies — grandparent pillar for all web vulnerability mitigation topics
Does React automatically escape all user input?
No. React escapes JSX text interpolation ({value}) but does not sanitize attribute bindings, dangerouslySetInnerHTML, or dynamically generated event handlers. Attribute contexts require explicit URL validation; HTML contexts require runtime sanitization through a wrapper like SanitizedHTML.
How do I safely render markdown in Vue without XSS?
Disable raw HTML parsing in your markdown compiler (html: false in marked options or markdown-it). Pipe the generated HTML string through DOMPurify with a strict tag and attribute allowlist before binding to v-sanitize. Enforce a CSP script-src 'self' header to block any residual inline payloads.
What CI tools enforce frontend input escaping policies?
eslint-plugin-security, eslint-plugin-react, eslint-plugin-vue, and semgrep provide static analysis coverage. Integrate with husky and lint-staged for pre-commit enforcement. Use custom semgrep rules to detect unsanitized sinks regardless of surrounding import context, and map findings to your compliance dashboard for audit evidence under OWASP ASVS v4.0 §5.3.3.