Saltar al contenido
White / wsuites

Práctico 3 min de lectura

Una CSP estricta en un sitio estático, sin renunciar a la interacción

Cómo este sitio mantiene script-src sin 'unsafe-inline' y aun así ejecuta cambio de tema, efectos de scroll y un formulario de contacto protegido.

Publicado

Casi todas las Content Security Policy fallan igual. Alguien añade la cabecera, media página se rompe, 'unsafe-inline' entra en script-src y la política deja de ser una política: pasa a ser decoración. Un atacante que encuentre un punto de inyección sigue pudiendo ejecutar código, que es justo lo que la cabecera existía para impedir.

Este sitio publica la versión estricta. Esta es la política completa:

default-src 'self';
script-src 'self' https://challenges.cloudflare.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self';
font-src 'self';
connect-src 'self' https://challenges.cloudflare.com;
frame-src https://challenges.cloudflare.com;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests

Sin 'unsafe-inline' en script-src, sin 'unsafe-eval', sin orígenes comodín. Todo lo demás se deriva de esa única restricción.

La regla que obliga a una estructura mejor

Si el script inline está prohibido, cada línea de JavaScript tiene que vivir en un archivo servido desde tu propio origen. Suena a limitación. En la práctica es una regla de diseño que empuja a un sitio mejor: el comportamiento acaba en unos pocos archivos con nombre y con límites reales, en vez de repartido por el marcado en handlers sueltos.

En concreto, todo el comportamiento de cliente vive aquí en cuatro archivos bajo /public/scripts/. Cada uno se protege contra la doble inicialización, porque con enrutado en cliente se le puede pedir arrancar más de una vez:

(() => {
  if (window.portfolioFxLoaded) return;
  window.portfolioFxLoaded = true;
  // ...
})();

El marcado entonces lleva datos, nunca lógica. Un componente que necesita cadenas localizadas en tiempo de ejecución las serializa en un atributo y el script las vuelve a leer:

<form data-contact-form data-contact-strings={JSON.stringify(messages)}>
function readStrings(form) {
  try {
    return { ...DEFAULT_STRINGS, ...JSON.parse(form.dataset.contactStrings) };
  } catch {
    return DEFAULT_STRINGS;
  }
}

Ese try está puesto a propósito. Leer un atributo propio nunca debería poder tumbar el formulario.

Lo único que una CSP estricta complica de verdad

El cambio de tema. Para evitar el destello de la paleta equivocada, el tema tiene que resolverse antes del primer pintado, que es exactamente para lo que se suele usar un script inline en el <head>.

El sustituto es un script externo deliberadamente bloqueante:

<script is:inline src="/scripts/theme.js"></script>

Sin defer y sin async. Es del mismo origen, la conexión ya está abierta y pesa unos cientos de bytes, así que resuelve el tema y lo aplica a <html> antes de que se renderice el body:

apply(storedTheme() ?? systemTheme());

Este es el único sitio donde “hazlo bloqueante” es la respuesta correcta. En todos los demás, defer es lo adecuado.

Verificarla en vez de confiar en ella

Una política que nunca se comprueba se degrada. El script de build de este sitio recorre el HTML generado y falla si aparece cualquier script inline:

for (const match of html.matchAll(/<script\b([^>]*)>/g)) {
  const src = match[1].match(/\bsrc="([^"]+)"/)?.[1];
  if (!src) {
    assert(
      /\btype="application\/ld\+json"/.test(match[1]),
      `${route} contains inline executable JavaScript`,
    );
  }
}

JSON-LD es la única excepción, porque type="application/ld+json" son datos, no un script ejecutable, y la CSP lo trata como tal.

El mismo script comprueba que script-src siga conteniendo 'self' y siga sin contener 'unsafe-inline'. Si alguien afloja la cabecera más adelante para que algo funcione, el build falla y esa decisión se toma de forma consciente en vez de por accidente.

Lo que cuesta

Con honestidad: algo de comodidad. Los embeds de terceros que inyectan script inline no funcionarán sin una excepción explícita, y hay que pensar dónde vive cada pieza de comportamiento.

Lo que se gana es una política que de verdad sostiene. style-src todavía permite estilos inline, y esa es una brecha real que queda: está ahí porque el framework emite atributos de estilo con ámbito, y cerrarla exigiría hashes por build. Prefiero decirlo abiertamente antes que fingir que la política es perfecta.

La regla que me llevo de todo esto: una cabecera de seguridad que no has probado contra tu propia salida de build es una suposición, no un control.