FlawOpen

flawopen.com/xss/Javascript

● CWE-79 · Alta
Investigación · FlawOpen

XSS en JavaScript: protege DOM, React y Express

Los destinos DOM/React inseguros y los enlaces pueden ejecutar JavaScript. Usa API de texto, sanea HTML, valida URL y aplica Trusted Types (CWE-79).

💡 Explicación en Lenguaje Sencillo (ELI5)

Imagina un panel de indicaciones teatrales. Si el regidor trata cada nota del público como una instrucción escénica, una nota puede añadir una escena inesperada; si la muestra como diálogo normal, sigue siendo texto. En el navegador, innerHTML y dangerouslySetInnerHTML de React piden al DOM que interprete el texto como marcado, mientras textContent lo muestra literalmente. Escapa el texto normal, sanea el poco HTML que permitas y valida los esquemas de URL antes de crear enlaces.

Conceptos Clave y Términos

innerHTML vs textContent
Asignar a innerHTML, outerHTML o insertAdjacentHTML hace que el navegador interprete la cadena como HTML, así que un <img onerror> en ella se convierte en un elemento activo. textContent y createTextNode insertan solo texto; nada del valor se interpreta como marcado.
Template literals in res.send()
res.send() de Express envía las cadenas como text/html por defecto. Un template literal evalúa ${req.query.q} antes del envío, así que la respuesta contiene lo que el usuario puso en la query string. req.query.q también puede ser un array o un objeto, así que comprueba el tipo.
dangerouslySetInnerHTML + DOMPurify
React escapa el texto en JSX automáticamente; dangerouslySetInnerHTML es la salida explícita. Pasa el valor por DOMPurify.sanitize(), que lo analiza con el propio parser del navegador y elimina scripts, manejadores de eventos y URLs peligrosas antes de que React lo inserte.
javascript: URLs in href
JSX escapa el valor del atributo, pero href="javascript:..." es una URL válida que ejecuta código al hacer clic. Analízala con new URL() y permite solo http: y https:; el parser de URLs elimina espacios y tabulaciones iniciales que las comprobaciones simples de prefijo pasan por alto.
Trusted Types
Con la directiva CSP require-trusted-types-for 'script', los navegadores compatibles rechazan cadenas simples en sinks como innerHTML y solo aceptan objetos TrustedHTML creados por una política que tú defines, normalmente una que llama a DOMPurify. Los sinks nuevos no pueden colarse sin que se note.

Mapeos de marcos

Flujo de Ataque Paso a Paso

Paso 1

La entrada llega a un sink

Una búsqueda, un campo de perfil cargado desde una API o un enlace guardado por otro usuario llega como cadena y se pasa a innerHTML, dangerouslySetInnerHTML, href o res.send().

Paso 2

El valor se convierte en marcado

Con el valor de prueba inofensivo <img src=x onerror=alert(1)>, innerHTML y res.send() producen un elemento <img> real; un enlace guardado javascript:alert(1) se vuelve un script clicable.

Paso 3

El navegador lo ejecuta

La imagen no carga y dispara onerror, o el usuario hace clic en el enlace. El código se ejecuta de inmediato, sin ninguna otra acción del atacante.

Paso 4

El script actúa como el sitio

Se ejecuta con el origen de la página: puede leer el DOM, llamar a las APIs del sitio con las cookies de la víctima o los tokens de localStorage, y cambiar lo que ve el usuario.

Código Fuente: Vulnerable vs. Seguro

IMPLEMENTACIÓN VULNERABLE
// profile.jsx: cada función de abajo deja que una cadena se interprete como HTML
import express from 'express';

const app = express();

// Express: el valor de la query entra en el HTML antes del envío
app.get('/search', (req, res) => {
  res.send(`<h1>Results for ${req.query.q}</h1>`);
});

// innerHTML interpreta name como marcado
export function showGreeting(el, name) {
  el.innerHTML = `Hello, ${name}`;
}

// React inserta el HTML tal como llega
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: html }} />;
}

// una URL javascript: se ejecuta al hacer clic en el enlace
export function Website({ url }) {
  return <a href={url}>Website</a>;
}
PARCHE SEGURO Y ROBUSTO
// profile.jsx: el texto sigue siendo texto; HTML y URLs se comprueban antes
import DOMPurify from 'dompurify';
import escapeHtml from 'escape-html';
import express from 'express';

const app = express();

// solo se aceptan cadenas, y se escapan como HTML
app.get('/search', (req, res) => {
  const q = typeof req.query.q === 'string' ? req.query.q : '';
  res.send(`<h1>Results for ${escapeHtml(q)}</h1>`);
});

// textContent nunca interpreta marcado
export function showGreeting(el, name) {
  el.textContent = `Hello, ${name}`;
}

// DOMPurify elimina scripts y manejadores de eventos antes de insertar
export function Bio({ html }) {
  return <div className="bio" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(html) }} />;
}

// solo sobreviven URLs http y https; lo demás se convierte en #
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>;
}

Lista de Verificación de Seguridad para Ingeniería

Fuentes