Seguridad en HTML
En esta lección
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>
| Valor | Qué hace |
|---|---|
noopener | La página nueva recibe window.opener como null: no puede tocar tu pestaña |
noreferrer | Ademá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:
| Valor | Qué se envía |
|---|---|
no-referrer | Nada |
origin | Solo el dominio: https://clubvertical.example/ |
same-origin | La URL completa solo a tu propio dominio; a los demás, nada |
strict-origin-when-cross-origin | URL 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-url | Siempre 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-referrerosame-originen 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:
| Permiso | Qué permite |
|---|---|
allow-scripts | Ejecutar JavaScript |
allow-forms | Enviar formularios |
allow-popups | Abrir ventanas o pestañas nuevas |
allow-same-origin | Mantener su origen real (sus cookies y su almacenamiento) |
allow-modals | Usar alert(), confirm() y similares |
allow-downloads | Descargar archivos |
allow-top-navigation-by-user-activation | Cambiar 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-scriptsyallow-same-origincon contenido de tu mismo dominio. Con los dos, el script del iframe puede acceder a la página padre y quitarse el atributosandboxa 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
| Directiva | Controla |
|---|---|
default-src | Todo lo que no tenga regla propia |
script-src | Scripts |
style-src | Hojas de estilo |
img-src | Imágenes |
font-src | Fuentes |
connect-src | Peticiones con fetch |
frame-src | Lo que se puede cargar en tus iframes |
frame-ancestors | Quién puede meter tu página en un iframe (solo en cabecera) |
form-action | A dónde pueden enviarse los formularios |
object-src | Plugins antiguos; ponlo siempre a 'none' |
upgrade-insecure-requests | Convierte 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>
integritylleva 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-passwordal iniciar sesión ynew-passwordal 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". Conget, 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:
- Abrir DevTools, borrar el
requiredo cambiar elmaxy enviar. - 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"sinrel: hoy no es grave en navegadores modernos, pero escriberel="noopener"igualmente y añadenoreferreren 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,
onclicky recursos de terceros. Empieza conReport-Only. - Poner
frame-ancestorsen 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. integritycon una URL sin versión: en cuanto la librería se actualiza, el script deja de cargar.- Fiarse de
required,hiddenomaxlength: 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
| Riesgo | Qué usar |
|---|---|
| La pestaña nueva controla la tuya | rel="noopener" (y noreferrer si no quieres enviar la URL) |
| Filtrar la URL a otros servidores | referrerpolicy o <meta name="referrer"> |
| Un iframe ajeno hace más de la cuenta | sandbox 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 HTTPS | URLs https:// y upgrade-insecure-requests |
| Un CDN sirve un archivo modificado | integrity + crossorigin="anonymous" + versión fija |
| Contraseñas mal gestionadas | autocomplete="current-password" / new-password y method="post" |
| Datos manipulados al enviar | Validar 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).
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.