Saltar al contenido
seguridad-html.html · devschool

Seguridad en HTML

Lección 32 de 39 · 13 min de lectura · Actualizado el

En esta lección
  1. Enlaces a otra pestaña: noopener y noreferrer
  2. Controlar lo que se cuenta: referrerpolicy
  3. iframes: sandbox y allow
  4. Content-Security-Policy (CSP)
  5. Contenido mixto
  6. Scripts de un CDN: integrity (SRI)
  7. Formularios: autocomplete y datos sensibles
  8. No confíes en la validación del navegador
  9. Errores frecuentes
  10. Resumen

Cuando piensas en seguridad web, seguramente te vienen a la cabeza contraseñas, servidores y bases de datos. Pero tu HTML también cuenta: un enlace mal escrito puede dejar que otra web controle tu pestaña, un <iframe> puede ejecutar código que no has revisado y un script cargado desde un servidor ajeno puede cambiar sin que te enteres. En esta lección verás los atributos y cabeceras que cierran esas puertas, y por qué nunca debes confiar en lo que llega de un formulario.

Nada de esto sustituye a un servidor bien protegido (lo verás en seguridad en PHP), pero son capas baratas que cualquier web debería tener.

Enlaces a otra pestaña: noopener y noreferrer

En la lección de enlaces viste que target="_blank" abre el enlace en una pestaña nueva. El problema es que, históricamente, la página nueva recibía una referencia a la tuya en window.opener, y con ella podía hacer esto:

// Código malicioso en la página que el usuario acaba de abrir
window.opener.location = 'https://banco-falso.example/login';

El usuario sigue leyendo en la pestaña nueva y, cuando vuelve a la tuya, ve una copia falsa de tu página de inicio de sesión. Este ataque se llama tabnabbing inverso (robar la pestaña original).

La solución es el atributo rel:

<a href="https://foro-escalada.example/hilo/123" target="_blank" rel="noopener noreferrer">
  Hilo del foro (se abre en otra pestaña)
</a>
ValorQué hace
noopenerLa página nueva recibe window.opener como null: no puede tocar tu pestaña
noreferrerAdemás, no envía a la otra web la dirección de la que viene el usuario

Los navegadores actuales ya aplican noopener por defecto a todo enlace con target="_blank", pero conviene escribirlo igualmente: es explícito, protege a usuarios con navegadores antiguos y cualquiera que lea tu código entiende la intención.

¿Cuándo añadir noreferrer? Cuando enlazas a webs externas en las que no confías del todo, o cuando la dirección de tu página contiene datos que no deberían salir de ella. Si te interesa que la otra web sepa que le mandas visitas (por ejemplo, un enlace a un socio), quédate solo con noopener.

Controlar lo que se cuenta: referrerpolicy

Cada vez que el navegador pide un recurso (una página enlazada, una imagen, un script), suele enviar la cabecera Referer con la dirección de la página en la que estaba. Es útil para estadísticas, pero puede filtrar información. Imagina esta URL:

https://clubvertical.example/cambiar-clave?token=8f3a91c2

Si esa página carga una imagen de otro servidor y se envía la URL completa, el otro servidor recibe el token. El atributo referrerpolicy decide qué se envía:

ValorQué se envía
no-referrerNada
originSolo el dominio: https://clubvertical.example/
same-originLa URL completa solo a tu propio dominio; a los demás, nada
strict-origin-when-cross-originURL completa a tu dominio, solo el dominio a los demás y nada si se pasa de HTTPS a HTTP. Es el valor por defecto
unsafe-urlSiempre la URL completa. Evítalo

Se puede poner en <a>, <img>, <iframe>, <script> y <link>, o para toda la página con una etiqueta <meta>:

<!-- Solo esta imagen -->
<img src="https://estadisticas.example/pixel.png" alt="" referrerpolicy="no-referrer">

<!-- Toda la página -->
<meta name="referrer" content="same-origin">

Consejo: el valor por defecto ya es razonable. Usa no-referrer o same-origin en páginas que lleven datos privados en la URL, y mejor aún: no pongas datos privados en la URL.

iframes: sandbox y allow

Un <iframe> mete otra página dentro de la tuya (lo viste en audio, vídeo e iframes). Si esa página no es tuya, conviene limitar lo que puede hacer.

sandbox: la caja de arena

Con sandbox vacío, el contenido del iframe queda encerrado: no ejecuta scripts, no envía formularios, no abre ventanas, no puede navegar tu página y se trata como si viniera de un origen desconocido (sin acceso a cookies ni a localStorage). Después, das permisos uno a uno:

PermisoQué permite
allow-scriptsEjecutar JavaScript
allow-formsEnviar formularios
allow-popupsAbrir ventanas o pestañas nuevas
allow-same-originMantener su origen real (sus cookies y su almacenamiento)
allow-modalsUsar alert(), confirm() y similares
allow-downloadsDescargar archivos
allow-top-navigation-by-user-activationCambiar la página principal, pero solo si el usuario hace clic
<!-- Un widget del tiempo: necesita scripts, nada más -->
<iframe src="https://widgets-tiempo.example/santander"
        sandbox="allow-scripts"
        title="Previsión del tiempo en Santander"
        loading="lazy"></iframe>

Pruébalo en el editor de esta lección: el mismo código del reloj funciona en el segundo iframe y no en el primero.

Cuidado: no combines allow-scripts y allow-same-origin con contenido de tu mismo dominio. Con los dos, el script del iframe puede acceder a la página padre y quitarse el atributo sandbox a sí mismo, así que la protección desaparece.

allow: permisos del navegador

Algunas funciones del navegador (cámara, micrófono, ubicación, pantalla completa) están desactivadas por defecto dentro de los iframes de otros dominios. El atributo allow las activa, separadas por punto y coma:

<iframe src="https://www.youtube-nocookie.com/embed/VIDEO_ID"
        title="Vídeo: cómo hacer un nudo de ocho"
        allow="fullscreen; picture-in-picture; encrypted-media"
        referrerpolicy="strict-origin-when-cross-origin"
        loading="lazy"></iframe>

Concede solo lo que haga falta. Un mapa no necesita la cámara, y un vídeo no necesita tu ubicación.

Que no te metan a ti en un iframe

El caso contrario: una web maliciosa carga tu página en un iframe transparente y coloca encima un botón falso. El usuario cree que pulsa “Ver fotos” y en realidad pulsa “Borrar mi cuenta” en tu web. Esto se llama clickjacking. Se evita desde el servidor con la cabecera Content-Security-Policy: frame-ancestors 'self' (la verás a continuación). En código antiguo verás X-Frame-Options: DENY, que hace algo parecido.

Content-Security-Policy (CSP)

La política de seguridad de contenido es una lista de reglas que le dice al navegador de dónde puede cargar cada tipo de recurso tu página. Si alguien consigue colar un <script> en tu HTML (por ejemplo, en un comentario mal filtrado), el navegador se niega a ejecutarlo porque no está en la lista. A ese tipo de ataque, inyectar código en una página ajena, se le llama XSS (cross-site scripting).

Lo ideal es enviarla como cabecera HTTP desde el servidor:

Content-Security-Policy: default-src 'self'; img-src 'self' https://fotos.clubvertical.example; frame-ancestors 'self'; object-src 'none'

Si no controlas el servidor (por ejemplo, en un alojamiento estático), puedes ponerla en el <head>, con alguna limitación:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; img-src 'self' https://fotos.clubvertical.example; object-src 'none'; base-uri 'self'; form-action 'self'">

Las directivas más usadas

DirectivaControla
default-srcTodo lo que no tenga regla propia
script-srcScripts
style-srcHojas de estilo
img-srcImágenes
font-srcFuentes
connect-srcPeticiones con fetch
frame-srcLo que se puede cargar en tus iframes
frame-ancestorsQuién puede meter tu página en un iframe (solo en cabecera)
form-actionA dónde pueden enviarse los formularios
object-srcPlugins antiguos; ponlo siempre a 'none'
upgrade-insecure-requestsConvierte las peticiones http:// en https://

Y los valores: 'self' (tu propio dominio), 'none' (nada), un dominio concreto (https://cdn.example) o https: (cualquier sitio con HTTPS). Las comillas simples en 'self' y 'none' son obligatorias.

Lo que rompe una CSP

Con script-src 'self', el navegador bloquea todo el JavaScript escrito dentro del HTML:

<!-- Bloqueado: script en línea -->
<script>console.log('hola');</script>

<!-- Bloqueado: manejador de eventos en el HTML -->
<button onclick="reservar()">Reservar</button>

Esto es justo lo que quieres, porque los ataques XSS suelen inyectar código así. Por eso se recomienda tener el JavaScript en archivos .js y asignar los eventos con addEventListener, como verás en eventos. Existe el valor 'unsafe-inline' para permitirlo, pero anula gran parte de la protección.

Consejo: antes de activar una CSP en una web que ya funciona, envíala como Content-Security-Policy-Report-Only. El navegador no bloquea nada, solo muestra en la consola de DevTools qué habría bloqueado. Así corriges sin romper la web.

Contenido mixto

Una página servida por HTTPS que carga recursos por HTTP tiene contenido mixto. Lo que viaja por HTTP puede leerse y modificarse por el camino (en una wifi pública, por ejemplo), así que el candado de tu página deja de significar algo.

<!-- En una página https:// -->
<script src="http://cdn.example/menu.js"></script>   <!-- Bloqueado -->
<img src="http://fotos.example/rocodromo.jpg" alt="Sala de bloque"> <!-- Se intenta por https -->

Los navegadores actuales bloquean los scripts, hojas de estilo, iframes y peticiones fetch por HTTP, e intentan cargar por HTTPS las imágenes, audios y vídeos; si el servidor no lo admite, tampoco se cargan. La solución es sencilla: escribe siempre https:// y, si heredas una web antigua llena de enlaces http://, añade upgrade-insecure-requests a la CSP mientras los corriges. En código antiguo verás URLs que empiezan por //cdn.example/... (sin protocolo); hoy basta con https://.

Scripts de un CDN: integrity (SRI)

Un CDN es un servicio que reparte archivos (librerías, fuentes, iconos) desde servidores repartidos por el mundo. Es cómodo, pero estás ejecutando en tu página código de un servidor que no controlas. Si alguien lo modifica, tu web ejecutará la versión modificada.

La integridad de subrecursos (SRI, Subresource Integrity) lo evita: añades al <script> o al <link> un hash, una especie de huella digital del archivo. El navegador calcula la huella del archivo que descarga y, si no coincide, se niega a usarlo.

Supón que publicas este archivo en un CDN:

console.log('Reservas cargadas');

Calculas su hash SHA-384 en la terminal:

openssl dgst -sha384 -binary reservas.js | openssl base64 -A
# ypsSfvUBHUkW4hIbLPVXXstOXBosK6mkgJdfd4dYf13Nd+6cBBxxa6iblF2zD/gc

Y lo usas así:

<script src="https://cdn.clubvertical.example/js/reservas.js"
        integrity="sha384-ypsSfvUBHUkW4hIbLPVXXstOXBosK6mkgJdfd4dYf13Nd+6cBBxxa6iblF2zD/gc"
        crossorigin="anonymous" defer></script>
  • integrity lleva el algoritmo, un guion y el hash.
  • crossorigin="anonymous" es necesario para que el navegador pueda comprobar un archivo de otro dominio.
  • Si cambia un solo carácter del archivo, el hash cambia y el script no se ejecuta.

Los CDN públicos más conocidos (cdnjs, jsDelivr) muestran el atributo integrity ya calculado junto a cada archivo: cópialo entero. Por eso, enlaza siempre a una versión concreta (libreria@3.2.1), nunca a “la última”: si la librería se actualiza, el hash dejará de coincidir.

Formularios: autocomplete y datos sensibles

El atributo autocomplete (lo viste en validar formularios) también es una herramienta de seguridad, porque ayuda a los gestores de contraseñas a hacer su trabajo:

<form action="/entrar" method="post">
  <label for="usuario">Usuario</label>
  <input id="usuario" name="usuario" autocomplete="username" required>

  <label for="clave">Contraseña</label>
  <input id="clave" name="clave" type="password" autocomplete="current-password" required>

  <label for="codigo">Código del SMS</label>
  <input id="codigo" name="codigo" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}">

  <button>Entrar</button>
</form>
  • current-password al iniciar sesión y new-password al registrarse o cambiar la clave: el navegador puede proponer una contraseña larga y aleatoria.
  • No pongas autocomplete="off" en contraseñas. Los navegadores lo ignoran y, si funcionara, empujaría a la gente a usar claves cortas y repetidas.
  • Formularios con contraseñas o datos personales, siempre con method="post". Con get, los datos acaban en la URL, en el historial y en los registros del servidor.

No confíes en la validación del navegador

required, pattern, maxlength, min, disabled, readonly o type="hidden" son ayudas para el usuario, no barreras. Cualquiera puede:

  1. Abrir DevTools, borrar el required o cambiar el max y enviar.
  2. Enviar los datos sin pasar por tu formulario, con una herramienta de terminal:
curl -X POST https://clubvertical.example/inscripcion -d "edad=3" -d "cuota=0"

Un caso clásico es poner el precio en un campo oculto:

<!-- Mal: el precio lo decide el usuario -->
<input type="hidden" name="precio" value="35">

Basta con cambiar ese 35 por 1 en DevTools. El servidor debe calcular el precio a partir del producto, y comprobar de nuevo cada dato que reciba: que existe, que tiene el tipo y la longitud correctos, que está en el rango permitido. Lo verás en validar y sanear en PHP.

Y una norma más para cuando muestres datos de los usuarios: nunca insertes su texto como HTML. En JavaScript, usa textContent en lugar de innerHTML; en PHP, htmlspecialchars. Así un comentario con <script> se muestra como texto y no se ejecuta.

Errores frecuentes

  • target="_blank" sin rel: hoy no es grave en navegadores modernos, pero escribe rel="noopener" igualmente y añade noreferrer en enlaces externos dudosos.
  • sandbox="allow-scripts allow-same-origin" en un iframe de tu propio dominio: el contenido puede quitarse el sandbox. Elige uno de los dos.
  • Activar una CSP estricta de golpe: rompe scripts en línea, onclick y recursos de terceros. Empieza con Report-Only.
  • Poner frame-ancestors en la etiqueta <meta>: no funciona ahí; tiene que ir en la cabecera HTTP.
  • Enlazar a http:// en una web con HTTPS: los scripts se bloquean y el candado pierde sentido.
  • integrity con una URL sin versión: en cuanto la librería se actualiza, el script deja de cargar.
  • Fiarse de required, hidden o maxlength: el servidor debe validarlo todo.
  • autocomplete="off" en el campo de contraseña: perjudica a los gestores de contraseñas y no se respeta.

Resumen

RiesgoQué usar
La pestaña nueva controla la tuyarel="noopener" (y noreferrer si no quieres enviar la URL)
Filtrar la URL a otros servidoresreferrerpolicy o <meta name="referrer">
Un iframe ajeno hace más de la cuentasandbox con los permisos mínimos y allow solo lo necesario
Te meten en un iframe (clickjacking)frame-ancestors 'self' en la cabecera CSP
Scripts inyectados (XSS)Content-Security-Policy, sin JavaScript en línea, textContent
Recursos por HTTP en una web HTTPSURLs https:// y upgrade-insecure-requests
Un CDN sirve un archivo modificadointegrity + crossorigin="anonymous" + versión fija
Contraseñas mal gestionadasautocomplete="current-password" / new-password y method="post"
Datos manipulados al enviarValidar siempre en el servidor

En la siguiente lección aprenderás a escribir HTML para correos electrónicos, un terreno con reglas muy distintas a las de la web.

Pruébalo tú

Cambia el código y pulsa Ejecutar (o Ctrl + Enter).

seguridad-html.html
Resultado

Pon a prueba lo que has aprendido

[HTML] Un iframe de tu propio dominio lleva sandbox="allow-scripts allow-same-origin". ¿Qué problema hay?

[HTML] ¿Dónde debe ponerse la directiva frame-ancestors de una CSP?

[HTML] Antes de activar una CSP estricta en una web que ya funciona, ¿qué conviene hacer?

[HTML] ¿Para qué sirve el atributo integrity (SRI) en un <script> de un CDN?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.