learning-front

Nivel 8 · Calidad: que no se rompa en producción

Seguridad en el frontend

El XSS ahora en React: por qué `{ }` te protege por defecto y `dangerouslySetInnerHTML` abre la puerta, cuándo sanear con DOMPurify, las URLs que ejecutan código, por qué los secretos nunca viven en el cliente y los patrones peligrosos que el código generado por IA cuela sin avisar.

Has pasado el nivel asegurándote de que la app no se rompa: tests, integración, e2e. Este capítulo cambia el eje a algo distinto: que no te la cuelen. La seguridad en el frontend no va de que tu código falle, sino de que un dato que no controlas —lo que escribe un usuario, lo que devuelve una API— acabe haciendo algo que tú nunca quisiste.

El XSS ya lo viste de pasada en el Nivel 2, con el DOM a pelo: meter HTML de un usuario con innerHTML ejecuta su código, y textContent lo neutraliza mostrándolo como texto. Aquí lo retomamos en React, que añade su propia versión de las dos caras de esa moneda.

React escapa por ti (lo que el { } te da gratis)#

La buena noticia primero: en React, el caso normal ya es seguro. Todo lo que metes entre llaves en JSX<p>{bio}</p>React lo inserta como texto, no como HTML. Convierte los caracteres peligrosos (<, >, &) en sus entidades, igual que hacía textContent. Así, si bio trae un <script> o un <img onerror>, sale escrito en la pantalla, no se ejecuta.

Mira las dos mitades del demo: el mismo dato hostil, pintado de dos formas. Arriba, entre llaves (seguro). Abajo, inyectado como HTML crudo (peligroso). Solo una ejecuta el ataque:

Al cargar, el marcador de arriba cambia a “CODIGO DEL ATACANTE EJECUTADO”: no lo tocaste tú, lo hizo el onerror del <img> que el navegador parseó. Esa es la diferencia entre mostrar un dato y ejecutarlo.

dangerouslySetInnerHTML: la puerta trasera#

Si React escapa todo lo que va entre llaves, ¿cómo se ejecutó el ataque? Porque la mitad de abajo no usa llaves: usa dangerouslySetInnerHTML, la única forma de meter HTML crudo en React. Recibe un objeto { __html: cadena } e inserta esa cadena tal cual, sin escapar nada.

El nombre no es un adorno: los autores de React lo llamaron “dangerously” a propósito, para que escribas “peligroso” cada vez que lo uses y te lo pienses. La regla es simple:

  • Con HTML que generas tú y que nunca incluye datos de usuario, es aceptable.
  • En cuanto un dato externo entra en esa cadena, cualquier <script> o atributo on* se ejecuta en el navegador de quien lo vea. Eso es un XSS.

¿Y qué? Que no es un fallo que veas en pantalla: la app funciona perfecta para ti. El ataque corre en el navegador de otra persona —roba su sesión, hace peticiones en su nombre— y tú ni te enteras. Por eso el XSS es tan peligroso: falla en silencio y en el lado de la víctima.

Y ojo con el autoengaño de “pero estos datos son de mi propia API, son de confianza”. Si esa bio la escribió un usuario en un formulario y tu backend la guardó y la devuelve, sigue siendo dato de usuario: el atacante solo dio un rodeo por la base de datos. Es el XSS almacenado, y es de los peores porque ataca a todo el que abra el perfil. La pregunta correcta nunca es ¿de dónde viene el dato?, sino ¿pudo un usuario influir en él en algún punto?.

Sanear: cuando SÍ necesitas pintar HTML#

Vale, ¿y si de verdad necesitas renderizar HTML? Caso real de empresa: las reseñas llegan con negritas y enlaces (un <strong>, un <a>) que sí quieres pintar. Escaparlas con { } te las convertiría en texto plano —verías los <strong> literales—. Aquí es donde escapar se queda corto y entra sanear.

No son lo mismo:

  • Escapar convierte todo el HTML en texto (lo que hace { }). Neutraliza el marcado entero.
  • Sanear filtra: quita lo peligroso (<script>, los on*, los href="javascript:") y deja el marcado seguro (<strong>, <em>, <a> con un href limpio).

La librería estándar para esto es DOMPurify. No te inventes un saneador a mano —es un clásico error: siempre se te escapa un vector—. Le pasas la cadena hostil, te devuelve una limpia, y esa es la que va a dangerouslySetInnerHTML:

Fíjate en el <pre> de abajo: el <strong> sobrevive, pero el <img onerror> ha desaparecido. DOMPurify se lo comió. Por eso el marcador sigue diciendo “(sin ejecutar)”: ya no hay ataque que ejecutar. Saneas una vez, justo antes de pintar, y solo cuando renderizar HTML es un requisito real.

URLs que ejecutan código#

El XSS no entra solo por el cuerpo del HTML; un enlace también es un vector. Un href puede llevar el esquema javascript:, y entonces el navegador ejecuta ese código al pulsarlo:

tsx
// heroe.web vale "javascript:robarSesion()".
// El escapado de React protege el TEXTO, no la semántica del href: esto ejecuta
// el código del atacante en cuanto el usuario hace clic.
<a href={heroe.web}>Web oficial</a>

La defensa es una allowlist de esquemas: dejas pasar solo los seguros (http, https, mailto) y descartas el resto. Y no lo hagas buscando el texto "javascript:" —mayúsculas, espacios y codificaciones lo esquivan—: usa el parser nativo URL, que entiende todo eso:

tsx
// Devuelve la URL solo si su esquema es seguro; si no, undefined.
function urlSegura(url: string): string | undefined {
  try {
    // new URL lanza si la cadena no es una URL válida; cae al catch.
    const analizada = new URL(url, window.location.origin);
    // Solo la damos por buena si su protocolo está en la lista permitida.
    return ["http:", "https:", "mailto:"].includes(analizada.protocol) ? url : undefined;
  } catch {
    // No parseable: la tratamos como hostil.
    return undefined;
  }
}

Y cuando abras un enlace externo con target="_blank", añade rel="noopener noreferrer". Sin él, la pestaña que abres recibe acceso a tu página vía window.opener y podría redirigirla a una copia de phishing mientras el usuario mira la otra (el tabnabbing). noopener corta ese puente.

Los secretos nunca viven en el cliente#

Cambiamos de frente. Una regla que el código generado por IA viola constantemente: cualquier cosa que llega al navegador es pública. No hay “secreto en el cliente”.

Lo viste en el Nivel 4: en Vite, una variable con prefijo VITE_ se incrusta en el bundle.

shell
# .env — esto NO es un secreto, aunque lo llames así:
VITE_API_KEY=clave-de-stripe-super-secreta
tsx
// Esta clave acaba LITERAL en el JavaScript que descarga el navegador.
// Cualquiera la lee abriendo las DevTools y mirando el código fuente.
fetch("https://api.pagos.com/cobrar", {
  headers: { Authorization: `Bearer ${import.meta.env.VITE_API_KEY}` },
});

El .gitignore evita subir el .env al repositorio, pero no impide que la clave viaje al cliente. La regla es dura y no tiene excepciones: las claves de API y los secretos viven en tu backend. El cliente le pide al backend, y es el backend —que el usuario no ve— quien usa el secreto. Lo mismo aplica a ejecutar texto como código: eval(texto) y new Function(texto) corren esa cadena como JavaScript; con datos de usuario, es ejecución de código arbitrario. Para datos usas JSON.parse (que no ejecuta nada); eval casi nunca es la respuesta.

Lo que la IA te cuela#

Esto es lo nuevo de 2026, y por eso le dedicamos su sitio. Cuando le pides código a una IA, te da algo que funciona en la demo… y que muchas veces tiene un agujero de seguridad, porque el modelo no sabe que ese dato es no confiable, ni que esa clave es un secreto. Tú eres la última línea de defensa. Estos cuatro patrones son los que más se cuelan:

  • dangerouslySetInnerHTML para “pintar el markdown” del usuario. Es el rey. Funciona, se ve bonito, y es un XSS almacenado. Fix: sanea con DOMPurify, o píntalo como texto si no necesitas el formato.
  • La API key metida en una variable VITE_ “para que el fetch funcione”. Te deja la app andando y el secreto expuesto en el bundle. Fix: el secreto al backend; el cliente le habla a él.
  • eval o new Function “para parsear” o “para evaluar la fórmula”. Ejecución de código arbitrario disfrazada de utilidad. Fix: JSON.parse para datos; una lógica acotada para fórmulas.
  • href={urlDelUsuario} y target="_blank" sin más. Abre la puerta a javascript: y al tabnabbing. Fix: allowlist de esquemas con URL, y rel="noopener noreferrer".

El patrón detrás de todos: la IA optimiza por “que funcione”, no por “que sea seguro frente a un dato hostil”. Revisa cada sugerencia con una sola pregunta —¿de dónde viene este dato y quién pudo tocarlo?— y la mayoría de estos agujeros se cierran solos.

Pruébalo#

Abre el primer demo y rompe el ataque tú mismo: en la mitad peligrosa, cambia dangerouslySetInnerHTML={{ __html: bioDelUsuario }} por {bioDelUsuario}. Recarga: el marcador ya no se dispara y el <img> aparece escrito como texto. Acabas de hacer, a mano, lo que React hace por defecto en cualquier { }. Vuelve a dejarlo como estaba para ver el ataque otra vez.

Comprueba lo que sabes#

Pregunta 1 de 10

En JSX escribes <p>{bio}</p> y bio es texto que escribió un usuario. ¿Por qué es seguro por defecto?

Tu turno#

FichaHeroe confía a ciegas en datos que llegan de la API. Blíndala: corta el XSS de la bio, cierra el enlace javascript: y, en el mejor tier, hazlo reutilizable. No cambias de dónde vienen los datos; cambias cómo los tratas. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un tier al siguiente: de neutralizar el ataque obvio, a cerrar el enlace, a dejar una utilidad robusta que no vuelvas a tener que arreglar.

Ejercicio · en esta página

Blinda la FichaHeroe

FichaHeroe pinta el perfil de un héroe con datos que llegan de la API (no los controlas). El starter es vulnerable: ejecuta código del atacante al cargar (la bio, vía dangerouslySetInnerHTML) y al pulsar el enlace (un href "javascript:"). No cambias de dónde vienen los datos: cambias cómo los tratas. Fíjate en el marcador de la ficha: si su texto cambia solo, un ataque se ejecutó.

Paso 1: Cortar el XSS obvio

  • Dejas de inyectar la bio con dangerouslySetInnerHTML.
  • La pintas entre llaves ({heroe.bio}) para que React la escape: el <img onerror> sale como texto.
  • El marcador ya no cambia al cargar: el ataque de la bio queda neutralizado.
Ver soluciones
// Los mismos datos hostiles de la API: no cambia de dónde vienen, cambia cómo
// los tratas.
const heroe = {
  nombre: "Sombra",
  // Sigue trayendo el ataque escondido en la bio…
  bio: '<img src="x" onerror="document.getElementById(\'marcador\').textContent = \'El atacante ejecutó código\'"> Hacker infiltrada.',
  // …y un href que no es http, sino código que corre al pulsar.
  web: "javascript:document.getElementById('marcador').textContent = 'Te robaron al pulsar'",
};

export default function FichaHeroe() {
  return (
    <article>
      <h2>{heroe.nombre}</h2>

      {/* El marcador ya no cambia solo: el ataque de la bio queda neutralizado. */}
      <p id="marcador">(sin ataques de momento)</p>

      {/* ARREGLADO: en vez de inyectar HTML, pintamos el dato entre llaves.
          React lo escapa por defecto, así que el <img onerror> sale como TEXTO
          y el navegador no lo ejecuta. Es el equivalente al textContent del DOM. */}
      <p>{heroe.bio}</p>

      {/* Sigue VULNERABLE: el href no se valida. Lo cerramos en el tier "mejor". */}
      <a href={heroe.web} target="_blank">
        Web oficial
      </a>
    </article>
  );
}

Por qué este nivel

  • El arreglo clave: cambiar dangerouslySetInnerHTML por {heroe.bio}. React escapa el valor, así que el <img onerror> se muestra como texto y no se ejecuta. Es el mismo salto que ir de innerHTML a textContent en el Nivel 2, ahora gratis y por defecto.
  • Su límite: solo cierra la bio. El href sigue saliendo crudo del dato, así que un "javascript:…" todavía ejecuta código al pulsar. Por eso es solo el tier OK.