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
  • dompurify and @types/dompurify installed (npm install dompurify)
  • TypeScript 5+ (examples use strict mode; adapt for plain JS as needed)
  • ESLint with eslint-plugin-react or eslint-plugin-vue installed
  • Working knowledge of JSX/SFC template syntax and how browser HTML parsing works

Expected Outcomes

  • All dangerouslySetInnerHTML calls guarded by a SanitizedHTML wrapper enforcing a DOMPurify allowlist
  • All v-html usages replaced by a v-sanitize custom directive with the same allowlist
  • Dynamic href/src bindings 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.

DOM Sink Classification for React and Vue Diagram showing untrusted input sources on the left flowing through four rendering context categories — text, attribute, HTML, and event/style — each with a risk level and required mitigation, ending at the browser DOM on the right. Untrusted Sources URL params API responses localStorage 3rd-party callbacks Form inputs WebSocket events Text Context — LOW RISK {"{value}"} · {"{{ value }}"} Auto-escaped by framework Attribute Context — MEDIUM href · src · :style Requires protocol validation HTML Context — HIGH RISK dangerouslySetInnerHTML · v-html Requires DOMPurify allowlist Event/Style Context — HIGH onClick · :style · calc() Requires schema validation Browser DOM Safe render if all sinks are hardened

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

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.