Volver a los artículos Número #1  / 

Blindar un sitio estático

Este sitio no tiene backend, ni inicio de sesión, ni base de datos. Aun así puede ser atacado. Esto es lo que podía salir mal, lo que cambié y cómo lo probé.

<script> cuela
El problemaCualquier texto que entre en la página como HTML puede ejecutar código en el navegador de un visitante.
<img onerror> javascript: <svg onload>
El ataqueMetí cargas XSS clásicas en el propio contenido del sitio para ver qué se ejecutaba.
CSP bloqueado.
La soluciónMostrar todo como texto y dejar que el navegador lo haga cumplir con CSP y Trusted Types.

Por qué un sitio estático también necesita seguridad

Es fácil pensar que un sitio estático no ofrece nada que atacar. No hay código de servidor que explotar ni base de datos que volcar. Pero la página ejecuta JavaScript en el navegador de cada visitante, y ahí es donde ocurre el cross-site scripting (XSS).

Mi sitio construye partes de la página a partir de datos: habilidades, certificaciones y la lista de artículos. La primera versión insertaba esos datos con innerHTML. Eso no es problema mientras escriba yo todos los datos, pero un solo título o enlace malo copiado de otro sitio se ejecutaría como código para cualquiera que abriera la página.

Qué podía salir mal

  • XSS a través del contenido: un título de artículo como <img src=x onerror=...> se ejecuta en cuanto la página lo muestra.
  • Enlaces maliciosos: una URL javascript: o data: en un enlace ejecuta código cuando alguien hace clic.
  • Código de terceros: las fuentes o scripts cargados desde otros dominios pueden cambiar, rastrear a los visitantes o estar comprometidos.
  • Reverse tabnabbing: un sitio abierto en una pestaña nueva puede redirigir mi pestaña original.

Qué cambié

1. El contenido es siempre texto, nunca HTML. Cada elemento se crea con createElement y se rellena con textContent. El navegador trata los datos como texto, así que el marcado que contengan se muestra, no se ejecuta.

2. Una Content Security Policy estricta. El navegador solo carga scripts, estilos, fuentes e imágenes de mi propio sitio. Los scripts en línea, eval, los plugins y los formularios están bloqueados.

default-src 'none'; script-src 'self'; style-src 'self';
font-src 'self'; img-src 'self'; connect-src 'self';
object-src 'none'; base-uri 'none'; form-action 'none';
require-trusted-types-for 'script'; trusted-types 'none'

3. Trusted Types. Las dos últimas líneas de la política hacen que el navegador rechace cualquier asignación de HTML sin tratar, como innerHTML. Aunque una edición futura mía reintrodujera el patrón antiguo, daría un error en lugar de abrir un agujero.

4. Una lista de URL permitidas. Cada enlace pasa por una función antes de llegar a la página. Solo pasan los enlaces https: y las rutas de mi propio sitio.

function safeUrl(value) {
  const u = new URL(value, location.href);
  if (u.protocol === "https:") return u.href;
  if (u.origin === location.origin) return u.href;
  return null; // javascript:, data: y cualquier otra cosa se descartan
}

5. Sin terceros. Las fuentes están alojadas en el propio sitio, que no hace peticiones a ningún otro dominio. No hay código externo en el que confiar ni rastreo de visitantes.

6. Los detalles pequeños. Los enlaces salientes usan rel="noopener noreferrer", la política de referrer es no-referrer y /.well-known/security.txt explica cómo informar de un problema.

Cómo lo probé

Metí cargas reales en los datos del sitio, lo serví en local y lo cargué en Chromium con la política activa.

CargaDóndeResultado
<img src=x onerror=...>Título del artículoMostrado como texto
<script>...</script>Etiqueta del artículoMostrado como texto
"><svg onload=...>Resumen del artículoMostrado como texto
javascript:...Enlace del artículo y del CVEnlace eliminado
data:text/html,...Enlace del artículoEnlace eliminado
<script> en línea en el HTMLCódigo de la páginaBloqueado por CSP
eval()Script del sitioBloqueado por CSP
Asignación a innerHTMLPágina en vivoBloqueado por Trusted Types

Ninguna de las cargas se ejecutó. Repetí la última comprobación en el sitio en vivo después de desplegarlo.

Lo que todavía falta

GitHub Pages no permite definir cabeceras de respuesta HTTP. Algunas protecciones solo funcionan como cabeceras y no como etiqueta <meta>: frame-ancestors contra el clickjacking, Strict-Transport-Security y Permissions-Policy. El riesgo es bajo porque en el sitio no hay nada a lo que engañar a alguien para que haga clic, pero sigue siendo una carencia.

La solución es servir el sitio desde un lugar donde yo controle las cabeceras. Ese es el siguiente número.