XSS in Modern Single Page Applications
Thilan Dissanayaka Application Security April 29, 2020

XSS in Modern Single Page Applications

If you’ve worked with modern JavaScript frameworks, you might think XSS is a solved problem. React escapes everything by default. Angular has its DomSanitizer. Vue auto-escapes interpolations. The frameworks do the right thing — until developers go out of their way to do the wrong thing.

And they do. All the time.

I’ve seen dangerouslySetInnerHTML used without sanitization in production React apps. I’ve seen javascript: URLs passed straight into href attributes. I’ve seen new Function() called with user input in template rendering logic. The framework gives you guard rails, and developers climb over them because “it’s the fastest way to get it working.”

This post isn’t a general XSS tutorial — if you need that foundation, check out the OWASP XSS Prevention Cheat Sheet. This is specifically about how XSS shows up in modern Single Page Applications — React, Next.js, and similar frameworks — where the attack surface is different from traditional server-rendered apps.

React’s Built-in XSS Protection

Before we break things, let’s understand what React gets right. By default, React escapes all values embedded in JSX:

const userInput = \"<script>alert('XSS')</script>\";
return <div>{userInput}</div>; 

React escapes this automatically and renders the literal text:

&lt;script&gt;alert('XSS')&lt;/script&gt;

The browser displays the script tag as text instead of executing it. This is React’s default behavior — JSX expressions are always escaped. You’d have to actively bypass this protection to introduce XSS.

And that’s exactly what the following patterns do.

1. DOM-based XSS via dangerouslySetInnerHTML

The name literally warns you. dangerouslySetInnerHTML is React’s escape hatch for injecting raw HTML into the DOM. It exists for legitimate use cases — rendering markdown content, displaying rich text from a CMS, embedding HTML emails. But when used with unsanitized user input, it’s a direct XSS vector.

Vulnerable Component:

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

function VulnerableHTMLRenderer() {
  const [content, setContent] = useState('');

  useEffect(() => {
    // Simulating content from URL parameters or API
    const urlParams = new URLSearchParams(window.location.search);
    const htmlContent = urlParams.get('content') || '<p>Default content</p>';
    setContent(htmlContent);
  }, []);

  // VULNERABLE: Directly rendering HTML without sanitization
  return (
    <div>
      <h2>Dynamic Content Renderer</h2>
      <div dangerouslySetInnerHTML= />
    </div>
  );
}

export default VulnerableHTMLRenderer;

Attack Vector:

http://localhost:3000?content=<img src=x onerror=alert('XSS in React!')>

The content parameter goes straight from the URL into dangerouslySetInnerHTML with zero sanitization. An attacker crafts a URL and sends it to a victim — when they click it, the injected HTML executes in their browser.

Secure Version:

The fix is simple — sanitize the HTML with DOMPurify before rendering:

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

function SecureHTMLRenderer() {
  const [content, setContent] = useState('');

  useEffect(() => {
    const urlParams = new URLSearchParams(window.location.search);
    const htmlContent = urlParams.get('content') || '<p>Default content</p>';
    
    // Sanitize HTML before setting state
    const cleanContent = DOMPurify.sanitize(htmlContent);
    setContent(cleanContent);
  }, []);

  return (
    <div>
      <h2>Secure Content Renderer</h2>
      <div dangerouslySetInnerHTML= />
    </div>
  );
}

export default SecureHTMLRenderer;

DOMPurify strips all dangerous elements (script tags, event handlers, etc.) while preserving safe HTML. It’s the gold standard for HTML sanitization in JavaScript.

2. XSS via Unsafe URL Handling

This one catches a lot of developers off guard. React escapes text content, but it does not validate URLs in href attributes. A javascript: URL is a perfectly valid href value — and when the user clicks it, the JavaScript executes.

import React, { useState } from 'react';

function VulnerableProfileCard({ userProfile }) {
  const [showDetails, setShowDetails] = useState(false);

  // VULNERABLE: Direct href assignment without validation
  const profileUrl = userProfile.website || '#';

  return (
    <div className=\"profile-card\">
      <img src={userProfile.avatar} alt=\"Profile\" />
      <h3>{userProfile.name}</h3>
      <p>{userProfile.bio}</p>
      
      {/* VULNERABLE: Can execute JavaScript URLs */}
      <a href={profileUrl} target=\"_blank\" rel=\"noopener\">
        Visit Website
      </a>
      
      <button onClick={() => setShowDetails(!showDetails)}>
        Toggle Details
      </button>
      
      {showDetails && (
        <div>
          {/* VULNERABLE: If description contains HTML */}
          <div dangerouslySetInnerHTML= />
        </div>
      )}
    </div>
  );
}

// Usage with malicious data:
const maliciousProfile = {
  name: \"John Doe\",
  website: \"javascript:alert('XSS via href!')\",
  description: \"<img src=x onerror=alert('Stored XSS')>\",
  avatar: \"profile.jpg\",
  bio: \"Software Developer\"
};

export default VulnerableProfileCard;

There are three XSS vectors in this single component:

  1. JavaScript URL in hrefjavascript:alert('XSS') executes when the link is clicked
  2. Data URLdata:text/html,<script>alert('XSS')</script> can also execute code
  3. Unsanitized HTML in descriptiondangerouslySetInnerHTML without DOMPurify

The secure version validates URLs and sanitizes HTML:

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

function SecureProfileCard({ userProfile }) {
  const [showDetails, setShowDetails] = useState(false);

  // Secure URL validation
  const validateUrl = (url) => {
    if (!url || url === '#') return '#';
    
    try {
      const parsedUrl = new URL(url);
      // Only allow http and https protocols
      if (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') {
        return url;
      }
      return '#';
    } catch {
      return '#';
    }
  };

  const safeUrl = validateUrl(userProfile.website);
  const safeDescription = DOMPurify.sanitize(userProfile.description || '');

  return (
    <div className=\"profile-card\">
      <img src={userProfile.avatar} alt=\"Profile\" />
      <h3>{userProfile.name}</h3>
      <p>{userProfile.bio}</p>
      
      <a href={safeUrl} target=\"_blank\" rel=\"noopener noreferrer\">
        {safeUrl === '#' ? 'No website available' : 'Visit Website'}
      </a>
      
      <button onClick={() => setShowDetails(!showDetails)}>
        Toggle Details
      </button>
      
      {showDetails && (
        <div>
          <div dangerouslySetInnerHTML= />
        </div>
      )}
    </div>
  );
}

export default SecureProfileCard;

The key: URL validation must be a whitelist (only allow http: and https:), not a blacklist (trying to block javascript:). Blacklists are always incomplete — there are too many encoding tricks and edge cases.

3. Client-Side Template Injection

This is less common but more dangerous. When developers build their own template rendering using eval(), new Function(), or setTimeout() with string arguments, they create code injection vulnerabilities that bypass React’s protections entirely.

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

function VulnerableDynamicRenderer() {
  const [template, setTemplate] = useState('');
  const [userData, setUserData] = useState({});

  useEffect(() => {
    // Simulating template from API or user input
    const urlParams = new URLSearchParams(window.location.search);
    const templateStr = urlParams.get('template') || 'Hello !';
    const name = urlParams.get('name') || 'Guest';
    
    setTemplate(templateStr);
    setUserData({ name });
  }, []);

  // VULNERABLE: Using eval or Function constructor for templating
  const renderTemplate = (template, data) => {
    try {
      // DANGEROUS: Never do this!
      const code = template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
        return `' + data.${key} + '`;
      });
      
      const finalCode = `return '${code}';`;
      const func = new Function('data', finalCode);
      return func(data);
    } catch (e) {
      return 'Template error';
    }
  };

  const renderedContent = renderTemplate(template, userData);

  return (
    <div>
      <h2>Dynamic Template Renderer</h2>
      <div dangerouslySetInnerHTML= />
    </div>
  );
}

export default VulnerableDynamicRenderer;
http://localhost:3000?template='+alert('XSS')+'&name=User
import React, { useState, useEffect } from 'react';

function SecureDynamicRenderer() {
  const [template, setTemplate] = useState('');
  const [userData, setUserData] = useState({});

  useEffect(() => {
    const urlParams = new URLSearchParams(window.location.search);
    const templateStr = urlParams.get('template') || 'Hello !';
    const name = urlParams.get('name') || 'Guest';
    
    // Validate template - only allow specific patterns
    const safeTemplate = validateTemplate(templateStr);
    setTemplate(safeTemplate);
    setUserData({ name: sanitizeString(name) });
  }, []);

  const validateTemplate = (template) => {
    // Only allow simple variable substitution
    const allowedPattern = /^[a-zA-Z0-9\s\{\}]+$/;
    if (!allowedPattern.test(template)) {
      return 'Hello !'; // Fallback to safe template
    }
    return template;
  };

  const sanitizeString = (str) => {
    return str.replace(/[<>'\"&]/g, (char) => {
      const entities = {
        '<': '&lt;',
        '>': '&gt;',
        '\"': '&quot;',
        \"'\": '&#x27;',
        '&': '&amp;'
      };
      return entities[char];
    });
  };

  // Safe template rendering without eval
  const renderTemplate = (template, data) => {
    return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
      return data[key] || '';
    });
  };

  const renderedContent = renderTemplate(template, userData);

  return (
    <div>
      <h2>Secure Template Renderer</h2>
      <div>{renderedContent}</div> {/* Using regular JSX instead of dangerouslySetInnerHTML */}
    </div>
  );
}

export default SecureDynamicRenderer;

The fix: never use eval(), new Function(), or setTimeout(string) with user input. Use simple string replacement with ``-style placeholders — and render the result as text through JSX, not as HTML.

4. XSS via Server-Side Rendering (SSR) Data Injection

Next.js and other SSR frameworks introduce a unique XSS surface. When server-side data is injected into the client-side page — through <script> tags, __NEXT_DATA__, or manual script injection — unsanitized data can execute as JavaScript.

// pages/profile/[id].js - Vulnerable Next.js page
import { GetServerSideProps } from 'next';

function ProfilePage({ profile, initialData }) {
  useEffect(() => {
    // VULNERABLE: Injecting server data directly into client-side script
    const script = document.createElement('script');
    script.innerHTML = `
      window.profileData = ${initialData};
      console.log('Profile loaded:', window.profileData);
    `;
    document.head.appendChild(script);
  }, [initialData]);

  return (
    <div>
      <h1>{profile.name}</h1>
      <div dangerouslySetInnerHTML= />
    </div>
  );
}

export const getServerSideProps = async ({ params }) => {
  // Simulating data from database that might contain XSS
  const profile = {
    name: \"John Doe\",
    bio: \"<script>alert('SSR XSS')</script>\"
  };

  // VULNERABLE: Not properly escaping data for client-side injection
  const initialData = JSON.stringify(profile);

  return {
    props: {
      profile,
      initialData
    }
  };
};

export default ProfilePage;
// pages/profile/[id].js - Secure Next.js page
import { GetServerSideProps } from 'next';
import DOMPurify from 'isomorphic-dompurify';
import { useEffect, useState } from 'react';

function SecureProfilePage({ profile }) {
  const [sanitizedBio, setSanitizedBio] = useState('');

  useEffect(() => {
    // Sanitize on client-side after hydration
    setSanitizedBio(DOMPurify.sanitize(profile.bio));
  }, [profile.bio]);

  return (
    <div>
      <h1>{profile.name}</h1>
      {sanitizedBio && (
        <div dangerouslySetInnerHTML= />
      )}
    </div>
  );
}

export const getServerSideProps = async ({ params }) => {
  const profile = {
    name: \"John Doe\",
    bio: \"<p>This is a safe bio</p>\"
  };

  // Server-side sanitization as well
  profile.bio = DOMPurify.sanitize(profile.bio);

  return {
    props: {
      profile
    }
  };
};

export default SecureProfilePage;

Comprehensive Prevention Strategies for React SPAs

1. Content Security Policy (CSP) Implementation

// In your React app's public/index.html or via headers
<meta http-equiv=\"Content-Security-Policy\" 
      content=\"default-src 'self'; 
               script-src 'self' 'unsafe-inline' https://trusted-cdn.com;
               style-src 'self' 'unsafe-inline';
               img-src 'self' data: https:;
               connect-src 'self' https://api.yourdomain.com;\">

2. Input Validation Hook

import { useState, useCallback } from 'react';

function useSecureInput(initialValue = '') {
  const [value, setValue] = useState(initialValue);
  const [error, setError] = useState('');

  const setSecureValue = useCallback((newValue) => {
    // Basic XSS prevention
    if (typeof newValue !== 'string') {
      setError('Invalid input type');
      return;
    }

    // Check for obvious XSS patterns
    const xssPatterns = [
      /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi,
      /javascript:/gi,
      /on\w+\s*=/gi,
      /<iframe/gi,
      /<object/gi,
      /<embed/gi
    ];

    const hasXSS = xssPatterns.some(pattern => pattern.test(newValue));
    
    if (hasXSS) {
      setError('Potentially malicious content detected');
      return;
    }

    setError('');
    setValue(newValue);
  }, []);

  return [value, setSecureValue, error];
}

// Usage:
function SecureForm() {
  const [comment, setComment, commentError] = useSecureInput('');

  return (
    <div>
      <textarea 
        value={comment} 
        onChange={(e) => setComment(e.target.value)}
      />
      {commentError && <div className=\"error\">{commentError}</div>}
    </div>
  );
}

3. Safe URL Validation Utility

const URLValidator = {
  isValidHttpUrl: (string) => {
    let url;
    try {
      url = new URL(string);
    } catch (_) {
      return false;
    }
    return url.protocol === \"http:\" || url.protocol === \"https:\";
  },

  sanitizeUrl: (url) => {
    if (!url || url === '#') return '#';
    
    // Remove javascript: and data: URLs
    if (url.toLowerCase().startsWith('javascript:') || 
        url.toLowerCase().startsWith('data:')) {
      return '#';
    }

    if (URLValidator.isValidHttpUrl(url)) {
      return url;
    }

    return '#';
  }
};

// Usage in component:
function SafeLink({ href, children }) {
  const safeHref = URLValidator.sanitizeUrl(href);
  
  return (
    <a href={safeHref} target=\"_blank\" rel=\"noopener noreferrer\">
      {children}
    </a>
  );
}

4. Secure HTML Rendering Component

import DOMPurify from 'dompurify';

function SecureHTMLContent({ htmlContent, allowedTags = [] }) {
  const defaultConfig = {
    ALLOWED_TAGS: allowedTags.length > 0 ? allowedTags : ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li'],
    ALLOWED_ATTR: ['href', 'target', 'rel'],
    ALLOW_DATA_ATTR: false
  };

  const sanitizedHTML = DOMPurify.sanitize(htmlContent, defaultConfig);

  return (
    <div dangerouslySetInnerHTML= />
  );
}

// Usage:
function BlogPost({ content }) {
  return (
    <SecureHTMLContent 
      htmlContent={content} 
      allowedTags={['p', 'h1', 'h2', 'h3', 'strong', 'em', 'a', 'ul', 'ol', 'li']}
    />
  );
}

5. Environment-Specific Security Configuration

// config/security.js
const securityConfig = {
  development: {
    enableCSP: false,
    strictValidation: false,
    logSecurityIssues: true
  },
  production: {
    enableCSP: true,
    strictValidation: true,
    logSecurityIssues: false
  }
};

export const getSecurityConfig = () => {
  return securityConfig[process.env.NODE_ENV] || securityConfig.production;
};

Testing XSS in React Applications

Manual Testing Payloads:

// URL parameters
?search=<img src=x onerror=alert('XSS')>

// Form inputs
<svg onload=alert('XSS')>
javascript:alert('XSS')
\"><script>alert('XSS')</script>

// JSON injection (for APIs)
{\"name\": \"<script>alert('XSS')</script>\"}

Automated Testing with Jest:

import { render, screen } from '@testing-library/react';
import DOMPurify from 'dompurify';
import SecureComponent from './SecureComponent';

describe('XSS Protection Tests', () => {
  test('should sanitize malicious script tags', () => {
    const maliciousInput = '<script>alert(\"XSS\")</script><p>Safe content</p>';
    const sanitized = DOMPurify.sanitize(maliciousInput);
    
    expect(sanitized).not.toContain('<script>');
    expect(sanitized).toContain('<p>Safe content</p>');
  });

  test('should handle javascript: URLs safely', () => {
    const maliciousUrl = 'javascript:alert(\"XSS\")';
    render(<SecureComponent url={maliciousUrl} />);
    
    const link = screen.getByRole('link');
    expect(link.getAttribute('href')).toBe('#');
  });
});

Key Takeaways for React XSS Prevention

  • Never use dangerouslySetInnerHTML without sanitization
  • Validate and sanitize all URLs before using in href attributes
  • Avoid using eval(), Function(), or setTimeout() with strings
  • Implement Content Security Policy (CSP)
  • Use libraries like DOMPurify for HTML sanitization
  • Validate all user inputs on both client and server side
  • Be extra careful with SSR data injection
  • Test your application with XSS payloads regularly

React’s built-in protections are excellent — they handle the 90% case correctly. But every time a developer reaches for dangerouslySetInnerHTML, eval(), javascript: URLs, or direct DOM manipulation, they’re stepping outside those protections.

The rule is simple: treat all user input as hostile. Sanitize HTML with DOMPurify. Validate URLs against a whitelist. Never execute strings as code. And test your application with XSS payloads regularly — the XSS payload list by PayloadsAllTheThings is a good starting point.

Thanks for reading!

ALSO READ
Blockchain 0x000 – Understanding the Fundamentals
May 21, 2020 Web3 Development

Imagine a world where strangers can exchange money, share data, or execute agreements without ever needing to trust a central authority. No banks, no intermediaries, no single point of failure yet...

Identity and Access Management (IAM)
May 11, 2020 Identity & Access Management

Who are you — and what are you allowed to do? That's the fundamental question every secure system must answer. And it's exactly what Identity and Access Management (IAM) is built to solve.

How I built a web based CPU Simulator
May 07, 2020 Pet Projects

As someone passionate about computer engineering, reverse engineering, and system internals, I've always been fascinated by what happens "under the hood" of a computer. This curiosity led me to...

Writing a Shell Code for Linux
Apr 21, 2020 Exploit Development

Shellcode is a small piece of machine code used as the payload in exploit development. In this post, we write Linux shellcode from scratch — starting with a simple exit, building up to spawning a shell, and explaining every decision along the way.

Exploiting a Stack Buffer Overflow on Windows
Apr 12, 2020 Exploit Development

In a previous tutorial we discusses how we can exploit a buffer overflow vulnerability on a Linux machine. I wen through all theories in depth and explained each step. Now today we are going to jump...

Access Control Models
Apr 08, 2020 Identity & Access Management

Access control is one of the most fundamental concepts in security. Every time you set file permissions, assign user roles, or restrict access to a resource, you're implementing some form of access control. But not all access control is created equal...

Exploiting a  Stack Buffer Overflow  on Linux
Apr 01, 2020 Exploit Development

Have you ever wondered how attackers gain control over remote servers? How do they just run some exploit and compromise a computer? If we dive into the actual context, there is no magic happening....

Basic concepts of Cryptography
Mar 01, 2020 Cryptography

Ever notice that little padlock icon in your browser's address bar? That's cryptography working silently in the background, protecting everything you do online. Whether you're sending an email,...

Common Web Application Attacks
Feb 05, 2020 Application Security

Web applications are one of the most targeted surfaces by attackers. This is primarily because they are accessible over the internet, making them exposed and potentially vulnerable. Since these...

Remote Code Execution (RCE)
Jan 02, 2020 Application Security

Remote Code Execution (RCE) is the holy grail of application security vulnerabilities. It allows an attacker to execute arbitrary code on a remote server — and the consequences are as bad as it sounds. In this post, we'll go deep into RCE across multiple languages, including PHP, Java, Python, and Node.js.