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:odata: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.
| Carga | Dónde | Resultado |
|---|---|---|
<img src=x onerror=...> | Título del artículo | Mostrado como texto |
<script>...</script> | Etiqueta del artículo | Mostrado como texto |
"><svg onload=...> | Resumen del artículo | Mostrado como texto |
javascript:... | Enlace del artículo y del CV | Enlace eliminado |
data:text/html,... | Enlace del artículo | Enlace eliminado |
<script> en línea en el HTML | Código de la página | Bloqueado por CSP |
eval() | Script del sitio | Bloqueado por CSP |
Asignación a innerHTML | Página en vivo | Bloqueado 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.