Verifica la integridad de CDN con SHA-384 y SRI
Para fijar un script de CDN a bytes confiables, genera un resumen SHA-384 a partir del archivo de
la versión aprobada y colócalo en el atributo integrity del script.
Esta guía ofrece un comando de hash que falla si falta la entrada y una demo local en el navegador
que demuestra que los scripts modificados no pueden ejecutarse.
Aunque sha384sum calcula el algoritmo correcto, su salida hexadecimal necesita
una codificación diferente para la integridad de subrecursos (SRI).
Comprende la integridad de subrecursos (SRI)
El navegador descarga el script, calcula el hash de su contenido y compara el resultado con el valor esperado en tu HTML antes de ejecutarlo. Una discrepancia bloquea la ejecución. El hash debe proceder de una compilación confiable o de una versión verificada de forma independiente: calcular el hash de una respuesta de CDN comprometida y aceptar ese valor equivaldría a aprobar el reemplazo. Tu HTML también forma parte de este límite de confianza; quien pueda cambiar tanto el script como su hash esperado puede eludir la comprobación.
SRI admite SHA-256, SHA-384 y SHA-512. SHA-384 es una base útil con un resumen más corto que el de SHA-512. Si proporcionas varios algoritmos, los navegadores usan el más robusto que admiten, sin recurrir a uno más débil tras una discrepancia. Consulta la especificación de SRI.
Genera un hash en la línea de comandos
Usa Bash y OpenSSL para el comando, además de Node.js para los servidores locales que se muestran más adelante. Este ejemplo se probó en Linux con Bash 5.3.15, OpenSSL 3.6.4, Node.js 26.8.1 y Chromium 152. No requiere instalar paquetes ni tener una cuenta de CDN. Guarda los archivos de los ejemplos en un directorio nuevo y vacío.
Guarda esto como demo.js. Representa el archivo de la versión aprobada que
normalmente obtendrías de tu compilación o del proveedor:
document.getElementById('status').textContent = 'Trusted script executed'
Guarda lo siguiente como sri.sh. Imprime un valor SRI si tiene éxito,
escribe diagnósticos en stderr si falla y nunca modifica el archivo de entrada:
#!/usr/bin/env bash
set -o pipefail
if [[ $# -ne 1 || ! -f "$1" || ! -r "$1" ]]; then
printf 'Usage: bash sri.sh readable-file\n' >&2
exit 1
fi
if digest=$(openssl dgst -sha384 -binary < "$1" | openssl base64 -A); then
printf 'sha384-%s\n' "$digest"
else
printf 'Could not generate SRI for %s\n' "$1" >&2
exit 1
fi
La opción -binary de OpenSSL produce los bytes
del resumen; base64 -A los codifica sin saltos
de línea. No codifiques en base64 el texto que imprime sha384sum: ese texto
representa el resumen en hexadecimal.
Comprobar el estado es tan importante como la codificación. Un pipeline como
cat missing.js | openssl … puede calcular el hash de un flujo vacío después de que falle
cat. Aquí, pipefail de Bash y la asignación con
comprobación de estado evitan que las lecturas o los comandos de OpenSSL fallidos impriman un valor
utilizable. La redirección de entrada también evita que los nombres de archivo que empiezan con un
guion se interpreten como opciones de OpenSSL. Un archivo vacío legible es una entrada válida y
tiene su propio resumen; un archivo ausente es un error.
Ejecuta el script directamente con Bash:
bash sri.sh ./demo.js
La salida empieza con sha384-, seguido de 64 caracteres base64. Los espacios
en blanco y los finales de línea de demo.js afectan el resultado, así que
calcula el hash de los bytes exactos que vas a servir.
Incorpora SRI en tu HTML o JSX
Para un script clásico de origen cruzado, establece tanto integrity como
crossorigin="anonymous". El servidor de recursos también debe enviar un encabezado
Access-Control-Allow-Origin adecuado. Si falta cualquiera de estas condiciones, el script puede
quedar bloqueado aunque sus bytes coincidan.
MDN explica el requisito de CORS.
En JSX, el atributo se escribe crossOrigin.
Guarda esto como server.ts. Sirve una página y su script en dos puertos de
loopback diferentes, por lo que el navegador los trata como orígenes distintos. El sistema
operativo elige los puertos disponibles. El hash procede de la shell, mientras que la respuesta
modificada conserva deliberadamente el hash esperado original.
import type { Server } from 'node:http'
import { once } from 'node:events'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
async function listen(server: Server, port = 0): Promise<string> {
server.listen(port, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing listening address')
return `http://127.0.0.1:${address.port}`
}
async function main(): Promise<void> {
const integrity = process.env.SRI
if (!integrity || !/^sha384-[A-Za-z0-9+/]{64}$/.test(integrity)) {
throw new Error('Set SRI to the trusted value from sri.sh')
}
const pagePort = Number(process.env.PAGE_PORT ?? 0)
if (!Number.isInteger(pagePort) || pagePort < 0 || pagePort > 65535) {
throw new Error('PAGE_PORT must be an integer from 0 to 65535')
}
const trusted = await readFile('./demo.js')
const changed = Buffer.concat([trusted, Buffer.from('\n// Changed after release\n')])
const assets = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
if (!['/demo.js', '/changed.js', '/no-cors.js'].includes(request.url ?? '')) {
response.writeHead(404).end()
return
}
if (request.url !== '/no-cors.js') response.setHeader('Access-Control-Allow-Origin', '*')
response.setHeader('Content-Type', 'text/javascript; charset=utf-8')
response.end(request.url === '/changed.js' ? changed : trusted)
})
const assetOrigin = await listen(assets)
const pages = createServer((request, response) => {
response.setHeader('Cache-Control', 'no-store')
if (request.method === 'POST' && request.url === '/api/security-alerts') {
request.resume()
console.log('Local SRI alert received')
response.writeHead(204).end()
return
}
const file = request.url === '/changed' ? 'changed.js' : request.url === '/no-cors' ? 'no-cors.js' : 'demo.js'
const cors = request.url === '/no-attribute' ? '' : 'crossorigin="anonymous"'
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(`<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SRI demo</title>
<h1>SRI demo</h1>
<nav aria-label="Test cases">
<a href="/">Trusted</a> | <a href="/changed">Changed bytes</a> |
<a href="/no-cors">Missing CORS header</a> | <a href="/no-attribute">Missing crossorigin</a>
</nav>
<p id="status" role="status">Waiting for script</p>
<script src="${assetOrigin}/${file}" integrity="${integrity}" ${cors}></script>
</html>`)
})
const pageOrigin = await listen(pages, pagePort)
console.log(`Open ${pageOrigin}`)
}
main().catch((error: unknown) => {
console.error('SRI demo failed:', error instanceof Error ? error.message : 'Unknown error')
process.exit(1)
})
Inícialo desde el directorio que contiene los tres archivos. && impide
el inicio si falla el cálculo del hash; ninguno de estos comandos escribe un archivo de salida ni
sobrescribe tus ejemplos:
expected_sri=$(bash sri.sh ./demo.js) &&
SRI="$expected_sri" node server.ts
Abre la URL impresa en tu navegador. La página Trusted muestra Trusted script executed. Cada uno de los demás enlaces deja Waiting for script en pantalla:
- Changed bytes sirve un comentario añadido. Incluso ese cambio inofensivo provoca una discrepancia en el resumen e impide la ejecución de todo el script.
- Missing CORS header sirve los bytes originales sin el permiso del servidor de recursos para leerlos desde otro origen.
- Missing crossorigin omite el atributo CORS del elemento, mientras que el servidor de recursos sigue enviando su encabezado de permiso.
Abre la consola del navegador para distinguir una discrepancia de integridad de un error de CORS.
Una respuesta HTTP exitosa por sí sola no demuestra que el script se haya ejecutado correctamente.
Detén ambos servidores con Ctrl+C. La demo lee demo.js una vez al iniciarse y
no almacena nada; su endpoint de alertas es solo un receptor local para el ejemplo opcional de
monitoreo que aparece más adelante. Usa HTTPS para tu página real y tu CDN. SRI no protege el HTML
entregado mediante una conexión que un atacante pueda reescribir.
Genera hashes en el navegador con la Web Crypto API
Para realizar diagnósticos, pega esta función en la consola de desarrollador de la página de la demo. Calcula el hash de los bytes obtenidos y rechaza los errores HTTP antes de leer una página de error como si fuera un script:
async function generateSRIHash(url) {
const response = await fetch(url, {
cache: 'no-store',
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error(`Unable to fetch resource: HTTP ${response.status}`)
const buffer = await response.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-384', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const binaryString = String.fromCharCode.apply(null, hashArray)
return `sha384-${btoa(binaryString)}`
}
En la página Trusted, ejecuta:
const resource = document.querySelector('script[integrity]')
console.log(await generateSRIHash(resource.src) === resource.integrity)
Esto imprime true. La misma comparación en la página
Changed bytes imprime false.
Conserva el hash esperado de la versión confiable; no lo reemplaces por lo que obtenga esta función.
Esta solicitud independiente no puede demostrar qué bytes se ejecutaron en una solicitud anterior
del script. El atributo integrity del elemento impone esa comprobación.
crypto.subtle.digest()
necesita un contexto seguro, como HTTPS o esta demo de loopback. Almacena temporalmente todo el
recurso en memoria. Las funciones auxiliares opcionales de JavaScript también requieren
AbortSignal.timeout(),
que limita cada solicitud, incluida la lectura de su cuerpo, a diez segundos de tiempo activo.
Ese tiempo de espera puede pausarse cuando se suspende un documento. Comprueba estas API por
separado de la compatibilidad con SRI si trabajas con navegadores antiguos; los navegadores sin
compatibilidad con SRI no aplican el atributo.
Carga scripts dinámicamente
Para un script añadido después de cargar la página, establece las propiedades de integridad y CORS antes de insertarlo. Pega esta función auxiliar en la misma consola o inclúyela en el JavaScript de tu aplicación:
async function loadScript(src, integrity) {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
Object.assign(script, { src, integrity, crossOrigin: 'anonymous' })
script.addEventListener('load', () => resolve())
script.addEventListener('error', () => reject(new Error(`Failed to load or verify ${src}`)))
document.head.append(script)
})
}
Implementa alternativas de respaldo
Usa un servidor espejo confiable con bytes idénticos y conserva el mismo hash esperado para la alternativa de respaldo. Esto intenta usar el respaldo una vez y propaga su fallo:
async function loadWithFallback(primary, backup, integrity) {
try {
await loadScript(primary, integrity)
} catch {
console.warn(`Primary failed, switching to ${backup}`)
await loadScript(backup, integrity)
}
}
Con ambas funciones auxiliares definidas y resource de la comparación anterior,
esto realiza la solicitud principal fallida, seguida de una carga verificada del recurso original
de la demo:
await loadWithFallback(
new URL('/changed.js', resource.src).href,
new URL('/demo.js', resource.src).href,
resource.integrity,
)
Monitorea los recursos de CDN en producción
Las comprobaciones periódicas pueden informar de cambios respecto de un hash confiable, pero las
pestañas del navegador pueden cerrarse o suspenderse. Usa una tarea programada independiente para
el monitoreo operativo. Para realizar un diagnóstico en el navegador, esta clase auxiliar mantiene
una sola consulta a la vez, aísla los fallos por recurso, comprueba el estado HTTP de las alertas y
permite detener y reiniciar las consultas. stop() impide futuras consultas;
una consulta activa termina.
class SRIMonitor {
#entries = new Map()
#intervalId
#checking = false
constructor(interval = 5 * 60_000) {
this.interval = interval
}
add(url, expectedHash) {
this.#entries.set(url, expectedHash)
}
async #check(url, expected) {
const actual = await generateSRIHash(url)
if (actual !== expected) {
console.warn(`[SRI] Mismatch for ${url}`)
const response = await fetch('/api/security-alerts', {
method: 'POST',
signal: AbortSignal.timeout(10_000),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, expected, actual }),
})
if (!response.ok) throw new Error(`Alert delivery failed: HTTP ${response.status}`)
}
}
async #poll() {
if (this.#checking) return
this.#checking = true
try {
for (const [url, hash] of this.#entries) {
try {
await this.#check(url, hash)
} catch (error) {
console.error('SRI monitor check failed:', error)
}
}
} finally {
this.#checking = false
}
}
start() {
if (this.#intervalId !== undefined) return
this.#intervalId = setInterval(() => {
void this.#poll()
}, this.interval)
}
stop() {
clearInterval(this.#intervalId)
this.#intervalId = undefined
}
}
Después de definir generateSRIHash, resource y la clase, prueba
esto en la consola de la demo:
const monitor = new SRIMonitor(1000)
monitor.add(new URL('/changed.js', resource.src).href, resource.integrity)
monitor.start()
// Run monitor.stop() when finished.
El navegador registra una discrepancia y el servidor imprime Local SRI alert received. El receptor de loopback confirma la recepción de las alertas y las descarta; no ofrece autenticación, almacenamiento ni notificaciones externas. Para producción, proporciona un endpoint con autenticación y limitación de solicitudes, y evita las URL que contengan credenciales en los informes. Un error de red o un fallo de CORS es una comprobación fallida, no una prueba de que el contenido haya cambiado.
Automatiza SRI dentro de tu pipeline de CI/CD
Genera los hashes después del último paso de minificación de tu compilación confiable, antes de
generar el HTML. Ejecuta sri.sh para cada script u hoja de estilos previstos
y haz que la compilación falle ante cualquier estado distinto de cero; haz que falle también si la
lista de recursos esperados está vacía. Guarda cada nombre de archivo y valor SRI en el manifiesto
de compilación y luego despliega juntos los recursos de ese manifiesto y el HTML generado. Esta
integración depende de tu sistema de compilación; la demo local anterior no instala un flujo de
trabajo de CI.
Prefiere las URL de recursos con versiones frente a los alias modificables, como
latest. Una actualización intencional de una dependencia requiere revisar
la nueva versión y, después, usar una nueva URL del recurso y su nuevo hash esperado. No hagas que
una comprobación de integridad fallida actualice automáticamente el valor esperado.
Soluciona problemas comunes
| Síntoma | Qué comprobar |
|---|---|
| El comando de hash falla | Comprueba la ruta, los permisos y la instalación de OpenSSL. Una entrada ausente no debe convertirse en el hash de un archivo vacío. |
| El navegador informa de una discrepancia de integridad | Compara la respuesta con la versión aprobada o el artefacto de compilación. Investiga los cambios inesperados; actualiza el hash solo después de aprobar los nuevos bytes. |
| El hash local difiere del de los bytes de la CDN | Comprueba la minificación, los comentarios de cabecera insertados y los finales de línea. La compresión HTTP normal se decodifica antes de la comprobación de integridad. |
| El navegador informa de un fallo de CORS | Conserva crossorigin="anonymous" y configura Access-Control-Allow-Origin de la CDN para tu página, o * para recursos públicos de acceso anónimo. |
| El script se ejecuta sin protección | Inspecciona el elemento real para verificar que contenga metadatos de integridad válidos y confirma la compatibilidad del navegador. Los metadatos vacíos o no compatibles no proporcionan la comprobación prevista. |
