Lección 10

¿Qué pasa cuando el HTML lo escribe un desconocido?

Un comentario, un nombre de usuario, el HTML de un proveedor: en cuanto un texto ajeno entra a tu página, la pregunta deja de ser de estilo y se vuelve de fronteras. Aquí se decide qué puede leer quién y qué se ejecuta en tu nombre.

01Qué es un origen y por qué el navegador lo respeta

Un origen es la dirección completa del edificio donde vive una página: el protocolo (https o http), el dominio y el puerto, el número que distingue un servicio de otro en la misma máquina (si no se escribe, https usa el 443). Cambia cualquiera de los tres y es otro edificio, aunque el nombre se parezca.

La política de mismo origen es la regla de la portería: el código de una habitación no puede leer el contenido de una habitación de otro edificio. No puede leer su árbol de elementos, ni las respuestas de sus peticiones, ni las cookies con las que el sitio te reconoce. Puede mandarle una petición, pero no leer lo que vuelve. Esa distinción importa: la política no impide que te manden cosas, impide que las leas.

El ataque de secuencias de comandos entre sitios (XSS, cross-site scripting) no necesita entrar a tu servidor: le basta con que un texto suyo termine interpretado como código. Si tu página arma HTML pegando cadenas y el nombre que llega es una etiqueta con un manejador, el navegador no ve un nombre: ve una orden y la ejecuta. El escape es la defensa: los caracteres con significado en HTML se escriben como entidades. El signo < se convierte en &lt;, el & en &amp; y las comillas en su entidad, así que el navegador pinta el texto en lugar de obedecerlo. El escape depende del contexto: no es igual en un párrafo que en un atributo o una URL.

Escapar no es limpiar: es decidir en qué idioma se habla. Si pintas el texto crudo con textContent, el navegador lo trata como texto; con innerHTML, como HTML. La misma cadena es inofensiva en un lugar y peligrosa en el otro.

Mismo origen cruza la puerta; otro origen no mismo origen sala A sala B https://app.mx la nota pasa origen distinto sala C https://otro.mx bloqueado la misma puerta deja leer la puerta no deja leer la respuesta protocolo + dominio + puerto; la ruta no cuenta
Las dos habitaciones comparten puerta y origen: el código de una puede leer a la otra. La nota del otro edificio llega a la pared, pero el navegador le niega la lectura.

02Por qué sucede: la lista blanca y el huésped

La política de seguridad de contenido (CSP) es una cabecera que el servidor manda con la página: una lista blanca de dónde puede venir el código que se ejecuta. Si llega un script de un dominio que no está, el navegador no lo ejecuta. El problema es unsafe-inline, la instrucción que permite scripts y manejadores escritos dentro del propio HTML. Ahí vive casi todo el XSS moderno, porque el atacante no necesita un dominio: solo meter su código en tu página. Con unsafe-inline la lista blanca deja de cubrir el punto que más se ataca. Las alternativas son un nonce, un valor aleatorio por respuesta que autoriza solo tus bloques, o un hash (una huella del contenido).

Un iframe es una ventana dentro de la ventana: otro documento, con su propio origen, pintado en un rectángulo. El atributo sandbox le quita permisos al huésped: sin scripts, sin formularios, sin navegación, sin acceso al almacenamiento, salvo los que enumeres. Un iframe con sandbox y sin allow-same-origin recibe un origen opaco: el navegador lo trata como "null", un documento sin dirección, que no puede leer nada del sitio que lo contiene ni identificarse. Eso es lo que quieres para HTML de terceros. El detalle que se copia mal en todas partes: allow-scripts y allow-same-origin juntos anulan el sandbox. Con los dos, el marco ejecuta código y además es del mismo origen que la página; desde ahí alcanza el documento que lo contiene y se quita el atributo sandbox a sí mismo. El sandbox deja de existir en la primera línea de JavaScript.

Para hablar entre orígenes distintos está postMessage: pasar una nota por debajo de la puerta. El emisor llama a la ventana del otro marco con un mensaje y un targetOrigin; el receptor escucha y recibe un evento con data, origin y source. La nota no se lee si no la recoges, y el receptor no debe creerla. Tres puertas: el origen exacto, comparado contra una lista y nunca con includes ni startsWith, porque un dominio que empieza igual pasa ese filtro; event.source, porque otro marco del mismo origen también pasaría la primera; y un esquema estricto del contenido, campo por campo, con tipos, longitudes y valores permitidos.

¿Y el targetOrigin: '*'? En el emisor es un agujero si manda datos privados: cualquier documento que esté en ese marco los recibe. Pero si el que manda es el marco opaco, su origen 'null' no identifica nada, y el padre solo puede apoyarse en event.source y en el esquema. Y si mandas hacia un marco opaco, '*' es lo único que funciona: ningún texto nombra un origen 'null'. Ahí la seguridad está en no mandar secretos y en validar al recibir.

La nota pasa por debajo de la puerta y cruza tres validaciones página principal · https://app.mx iframe · sandbox origen: "null" documento de terceros no puede leer el padre nota 1 origen 2 event.source 3 esquema handler del mensaje valida antes de usar postMessage: una nota por debajo de la puerta
El iframe tiene sandbox y por eso su origen es "null": no puede leer la página que lo contiene. La nota no se usa hasta pasar las tres validaciones, y ninguna sobra: cada una cubre un fallo distinto.
El mismo texto cambia de significado según el destino el mismo texto, dos destinos llega del usuario <img onerror=robar()> textContent pinta el texto; no lo obedece innerHTML crea el nodo y ejecuta onerror la orden corre en tu página texto HTML escapar es elegir el idioma del destino, no limpiar después
La cadena es la misma; el destino decide si el navegador la lee como caracteres o la ejecuta como etiquetas. Por eso el escape depende del contexto y no se arregla con una lista de palabras prohibidas.

03Cómo se ataca

La regla de oro es escapar donde el dato se vuelve interfaz, no limpiar después. Un texto inofensivo puede volverse peligroso en otro contexto; solo el renderizador sabe si está en un párrafo, un atributo o una URL.

Para la CSP, empieza con default-src 'none' y abre solo lo que la página necesita, primero en modo report-only para ver qué romperías. Para contenido de terceros, sandbox mínimo y nunca allow-scripts con allow-same-origin. Para los mensajes, valida en la frontera y usa el dato validado: nunca pases event.data a innerHTML.

// El iframe del editor avisa cuando terminó de cargar un documento.
const ORIGEN_EDITOR = 'https://editor.ejemplo.mx';

window.addEventListener('message', (evento) => {
  // Puerta 1: ¿de quién viene? Comparación exacta contra la lista.
  if (evento.origin !== ORIGEN_EDITOR) return;

  // Puerta 2: ¿es el marco que esperamos? Otro marco del mismo
  // origen también pasaría la puerta 1.
  if (evento.source !== marcoEditor.contentWindow) return;

  // Puerta 3: ¿el contenido tiene la forma esperada? Se valida
  // campo por campo y se descarta todo lo que no encaje.
  const datos = evento.data;
  if (typeof datos !== 'object' || datos === null) return;
  if (datos.tipo !== 'documento-listo') return;
  if (typeof datos.id !== 'string' || datos.id.length > 64) return;
  if (!/^[a-z0-9-]+$/.test(datos.id)) return;

  // Solo pasa un identificador validado; nunca HTML del mensaje.
  cargarDocumento(datos.id);
});

Fíjate en la última línea: el mensaje no se usa, se usa lo que sobrevivió a la validación. Esa diferencia es la que separa una frontera de un conducto.

04Cómo diagnosticarlo

La seguridad no se ve en la pantalla: se ve en las cabeceras, en el panel de seguridad y en el código que acepta datos de fuera.

HerramientaQué se miraCómo se ve el síntoma
DevTools › Security Si la página es segura, si hay contenido mixto y qué orígenes la cargan. Contenido mixto: un script viaja sin cifrar y cualquiera en la red lo cambia.
DevTools › Network › cabeceras La cabecera content-security-policy en la respuesta del documento. Sin cabecera no hay lista blanca; con unsafe-inline no cubre lo que más se ataca; en report-only no bloquea nada.
Auditoría de innerHTML Cada uso de innerHTML o dangerouslySetInnerHTML con datos que vienen de fuera. Una veintena de usos sugiere escape a mano; los que quedan necesitan un sanitizador (un limpiador que deja solo el HTML permitido) y una prueba con carga maliciosa.
Auditoría de postMessage Los addEventListener('message') y el handler completo. Sin comparación de origen, con includes, o con event.data dentro de innerHTML: el agujero está a la vista.
El error que se repite

Validar el origen y luego confiar en el contenido. El origen dice de qué edificio viene la nota; no dice que sea cierta ni que no traiga una etiqueta que tu página vaya a ejecutar.

Para decirlo en la entrevista

“An origin is protocol, host and port; the path never counts.”

“allow-scripts plus allow-same-origin turns the sandbox off.”

“Validate origin, source and shape before touching a single field.”

Comprueba que lo tienes

Cuatro preguntas. Al comprobar se marca la lección como completada.

Compara https://app.ejemplo.mx con https://app.ejemplo.mx:8443. ¿Son el mismo origen?

El iframe de un proveedor lleva sandbox="allow-scripts allow-same-origin" porque, dicen, así “funciona bien”. ¿Qué pasa con esa combinación?

Tu CSP dice script-src 'self' 'unsafe-inline'. Un atacante logra inyectar una etiqueta script en un comentario. ¿Se ejecuta?

Recibes un mensaje del iframe del editor. Ya comprobaste event.origin y usas event.data.html con innerHTML. ¿Qué falta?