FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · High
FlawOpen Security Research

JavaScript XSS: Secure DOM, React and Express

Unsafe DOM/React sinks and links can run injected JavaScript. Prefer text APIs; sanitize HTML, validate URLs and enforce Trusted Types (CWE-79).

💡 Plain English Explainer (ELI5)

Think of a theater cue board. If a stage manager treats every guest note as stage directions, a note can add an unexpected scene; if the note is displayed as ordinary dialogue, it stays text. In a browser, innerHTML and React’s dangerouslySetInnerHTML ask the DOM to parse supplied text as markup, while textContent displays it literally. Escape normal text, sanitize the small amount of HTML you intentionally allow, and validate URL schemes before creating links.

Core Concepts & Subsystem Terms

innerHTML vs textContent
Assigning to innerHTML, outerHTML or insertAdjacentHTML makes the browser parse the string as HTML, so an <img onerror> in it becomes a live element. textContent and createTextNode insert text only; nothing in the value is ever parsed as markup.
Template literals in res.send()
Express's res.send() sends a string as text/html by default. A template literal evaluates ${req.query.q} before sending, so the response contains whatever the user put in the query string. req.query.q can also be an array or object, so check its type.
dangerouslySetInnerHTML + DOMPurify
React escapes text in JSX automatically; dangerouslySetInnerHTML is the explicit opt-out. Run the value through DOMPurify.sanitize(), which parses it with the browser's own parser and removes scripts, event handlers and dangerous URLs before React inserts it.
javascript: URLs in href
JSX escapes the attribute value, but href="javascript:..." is a valid URL that runs code when clicked. Parse with new URL() and allow only http: and https:; the URL parser strips leading spaces and tabs that simple prefix checks miss.
Trusted Types
With the CSP directive require-trusted-types-for 'script', supported browsers refuse plain strings in sinks such as innerHTML and accept only TrustedHTML objects created by a policy you define, typically one that calls DOMPurify. New sinks cannot slip in unnoticed.

Framework mappings

Step-by-Step Attack Flow

Step 1

Input reaches a sink

A search query, a profile field loaded from an API, or a link stored by another user arrives as a string and is passed to innerHTML, dangerouslySetInnerHTML, href or res.send().

Step 2

The value becomes markup

With the benign test value <img src=x onerror=alert(1)>, innerHTML and res.send() produce a real <img> element; a stored javascript:alert(1) link becomes a clickable script.

Step 3

The browser runs it

The image fails to load and fires onerror, or the user clicks the link. The code runs immediately, with no further action from the attacker.

Step 4

The script acts as the site

It runs with the page's origin: it can read the DOM, call the site's APIs with the victim's cookies or tokens from localStorage, and change what the user sees.

Source Code: Flaw vs. Secure Implementation

UNPATCHED FLAW
// profile.jsx: each function below lets a string be parsed as HTML
import express from 'express';

const app = express();

// Express: the query value is placed in the HTML before sending
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML parses name as markup
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React inserts the HTML exactly as received
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// a javascript: URL runs when the link is clicked
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
HARDENED SECURE PATCH
// profile.jsx: text stays text; HTML and URLs are checked first
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// only strings are accepted, and they are HTML-escaped
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent never parses markup
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// DOMPurify removes scripts and event handlers before insertion
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// only http and https URLs survive; everything else becomes #
function safeHref(url) {
  try {
    const parsed = new URL(url, window.location.origin);
    return ['http:', 'https:'].includes(parsed.protocol) ? parsed.href : '#';
  } catch {
    return '#';
  }
}

export function Website({ url }) {
  return <a href={safeHref(url)}>Website</a>;
}

Engineering & System Hardening Checklist

Sources