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.