Verifique a integridade da CDN com SHA-384 e SRI
Para fixar um script de CDN nos bytes em que você confia, gere um digest SHA-384 a partir do arquivo
da versão aprovada e coloque-o no atributo integrity do script. Este passo a passo oferece um comando
de hash que falha quando a entrada está ausente e uma demonstração local no navegador que prova que
scripts alterados não podem ser executados. Embora sha384sum calcule o algoritmo certo, a saída
hexadecimal dele precisa de outra codificação para o Subresource Integrity (SRI).
Entenda o Subresource Integrity (SRI)
O navegador baixa o script, calcula o hash do conteúdo e compara o resultado com o valor esperado no seu HTML antes de executá-lo. Uma divergência bloqueia a execução. O hash deve vir de um build confiável ou de uma versão verificada de forma independente: calcular o hash de uma resposta comprometida da CDN e aceitar esse valor aprovaria a substituição. Seu HTML também faz parte dessa fronteira de confiança; quem consegue alterar tanto o script quanto o hash esperado pode burlar a verificação.
O SRI aceita SHA-256, SHA-384 e SHA-512. O SHA-384 é uma base útil, com um digest mais curto que o do SHA-512. Se você fornecer vários algoritmos, os navegadores usam o mais forte que suportam, sem recorrer a um mais fraco após uma divergência. Consulte a especificação do SRI.
Gere um hash na linha de comando
Use Bash e OpenSSL para o comando, além do Node.js para os servidores locais abaixo. Este exemplo foi testado no Linux com Bash 5.3.15, OpenSSL 3.6.4, Node.js 26.8.1 e Chromium 152. Ele não exige instalação de pacotes nem conta em CDN. Salve os arquivos de exemplo em um diretório novo e vazio.
Salve isto como demo.js. Ele faz o papel do arquivo da versão aprovada que você normalmente
obteria do seu build ou do publicador:
document.getElementById('status').textContent = 'Trusted script executed'
Salve o seguinte como sri.sh. Ele imprime um valor SRI em caso de sucesso, grava diagnósticos
no stderr em caso de falha e nunca modifica o arquivo 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
A opção -binary do OpenSSL produz os bytes do
digest; base64 -A os codifica sem quebras de linha.
Não codifique em base64 o texto impresso por sha384sum: esse texto representa o digest em
hexadecimal.
A verificação de status importa tanto quanto a codificação. Um pipeline como cat missing.js | openssl …
pode calcular o hash de um fluxo vazio depois que cat falha. Aqui, o pipefail do Bash e a
atribuição verificada impedem que leituras com falha ou comandos OpenSSL com falha imprimam um valor
utilizável. O redirecionamento de entrada também impede que nomes de arquivo iniciados por hífen
sejam interpretados como opções do OpenSSL. Um arquivo vazio legível é uma entrada válida e tem o
próprio digest; um arquivo ausente é um erro.
Execute o script diretamente com o Bash:
bash sri.sh ./demo.js
A saída começa com sha384-, seguido de 64 caracteres base64. Espaços em branco e finais de linha
em demo.js afetam o resultado, então calcule o hash exatamente dos bytes que você vai servir.
Incorpore o SRI no seu HTML ou JSX
Para um script clássico de outra origem (cross-origin), defina tanto integrity quanto
crossorigin="anonymous". O servidor de ativos também precisa enviar um cabeçalho Access-Control-Allow-Origin adequado. A
falta de qualquer uma dessas condições pode bloquear o script mesmo quando os bytes dele correspondem.
O MDN explica o requisito de CORS.
Em JSX, o atributo se escreve crossOrigin.
Salve isto como server.ts. Ele serve uma página e o script dela em duas portas de loopback
diferentes, de modo que o navegador as trate como origens diferentes. O sistema operacional escolhe
portas disponíveis. O hash vem do shell, enquanto a resposta alterada mantém deliberadamente o 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)
})
Inicie-o a partir do diretório que contém os três arquivos. O && impede a inicialização se
o cálculo do hash falhar; nenhum desses comandos grava um arquivo de saída nem sobrescreve seus
exemplos:
expected_sri=$(bash sri.sh ./demo.js) &&
SRI="$expected_sri" node server.ts
Abra a URL impressa no navegador. A página Trusted mostra Trusted script executed. Cada um dos outros links deixa Waiting for script na tela:
- Changed bytes serve um comentário adicionado. Mesmo essa alteração inofensiva causa uma divergência de digest e impede que o script inteiro seja executado.
- Missing CORS header serve os bytes originais sem a permissão do servidor de ativos para lê-los a partir de outra origem.
- Missing crossorigin omite o atributo CORS do elemento, enquanto o servidor de ativos continua enviando o cabeçalho de permissão.
Abra o console do navegador para distinguir uma divergência de integridade de um erro de CORS. Uma
resposta HTTP bem-sucedida, por si só, não comprova que o script foi executado com sucesso. Pare os
dois servidores com Ctrl+C. A demonstração lê demo.js uma vez na inicialização e não armazena
nada; o endpoint de alertas dela é apenas um receptor local para o exemplo opcional de monitoramento
abaixo. Use HTTPS na sua página e na sua CDN reais. O SRI não protege HTML entregue por uma conexão
que um invasor consiga reescrever.
Gere hashes no navegador com a Web Crypto API
Para diagnóstico, cole esta função no console de desenvolvedor da página de demonstração. Ela calcula o hash dos bytes obtidos e rejeita erros HTTP antes de ler uma página de erro como 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)}`
}
Na página Trusted, execute:
const resource = document.querySelector('script[integrity]')
console.log(await generateSRIHash(resource.src) === resource.integrity)
Isso imprime true. A mesma comparação na página
Changed bytes imprime false. Mantenha o hash esperado
da versão confiável; não o substitua pelo que esta função obtiver. Esta requisição separada não pode
provar quais bytes uma requisição anterior do script executou. Quem aplica essa garantia é o atributo
integrity do elemento.
crypto.subtle.digest()
exige um contexto seguro, como HTTPS ou esta demonstração em loopback. Ele mantém o recurso inteiro
em buffer na memória. Os helpers opcionais em JavaScript também exigem
AbortSignal.timeout(),
que limita cada fetch, incluindo a leitura do corpo, a dez segundos de tempo ativo. Esse tempo limite
pode pausar quando um documento é suspenso. Verifique essas APIs separadamente do suporte a SRI ao
visar navegadores mais antigos; navegadores sem suporte a SRI não aplicam o atributo.
Carregue scripts dinamicamente
Para um script adicionado depois do carregamento da página, defina as propriedades de integridade e CORS antes de inseri-lo. Cole este helper no mesmo console ou inclua-o no JavaScript da sua aplicação:
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)
})
}
Implemente fallbacks
Use um espelho confiável idêntico byte a byte e mantenha o mesmo hash esperado no fallback. Isto tenta o backup uma vez e propaga a falha dele:
async function loadWithFallback(primary, backup, integrity) {
try {
await loadScript(primary, integrity)
} catch {
console.warn(`Primary failed, switching to ${backup}`)
await loadScript(backup, integrity)
}
}
Com os dois helpers definidos e resource da comparação acima, isto faz a requisição primária que
falha, seguida de um carregamento verificado a partir do ativo original da demonstração:
await loadWithFallback(
new URL('/changed.js', resource.src).href,
new URL('/demo.js', resource.src).href,
resource.integrity,
)
Monitore ativos de CDN em produção
Verificações periódicas podem relatar alterações em relação a um hash confiável, mas abas do
navegador podem ser fechadas ou suspensas. Use uma tarefa agendada independente para o monitoramento
operacional. Para um diagnóstico no navegador, este helper garante uma verificação por vez, isola
falhas por recurso, verifica o status HTTP do alerta e permite parar e reiniciar as verificações
periódicas. stop() impede verificações futuras; uma verificação em andamento chega ao fim.
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
}
}
Depois de definir generateSRIHash, resource e a classe, experimente isto no console da
demonstração:
const monitor = new SRIMonitor(1000)
monitor.add(new URL('/changed.js', resource.src).href, resource.integrity)
monitor.start()
// Run monitor.stop() when finished.
O navegador registra uma divergência e o servidor imprime Local SRI alert received. O receptor em loopback confirma e descarta os alertas; ele não oferece autenticação, armazenamento nem notificações externas. Em produção, forneça um endpoint autenticado e com limite de taxa e evite URLs que contenham credenciais nos relatórios. Um erro de rede ou uma falha de CORS é uma verificação com falha, não evidência de que o conteúdo mudou.
Automatize o SRI no seu pipeline de CI/CD
Gere os hashes após a etapa final de minificação do seu build confiável, antes de renderizar o HTML.
Execute sri.sh para cada script ou folha de estilo pretendido e faça o build falhar em qualquer
status diferente de zero; faça-o falhar também se a lista de ativos esperados estiver vazia.
Armazene cada nome de arquivo e valor SRI no manifesto do build e depois implante juntos os ativos
desse manifesto e o HTML renderizado. Essa integração depende do seu sistema de build; a
demonstração local acima não instala um fluxo de trabalho de CI.
Prefira URLs de ativos versionadas a aliases mutáveis como latest. Uma atualização intencional
de dependência exige a revisão da nova versão, seguida de uma nova URL de ativo e do novo hash
esperado correspondente. Não faça uma verificação de integridade com falha atualizar automaticamente
o valor esperado.
Solucione armadilhas comuns
| Sintoma | O que verificar |
|---|---|
| O comando de hash falha | Verifique o caminho, as permissões e a instalação do OpenSSL. Uma entrada ausente não pode virar o hash de um arquivo vazio. |
| O navegador relata uma divergência de integridade | Compare a resposta com a versão aprovada ou com o artefato de build. Investigue uma alteração inesperada; atualize o hash somente depois de aprovar os novos bytes. |
| O hash local difere dos bytes da CDN | Verifique a minificação, banners injetados e finais de linha. A compressão HTTP comum é decodificada antes da verificação de integridade. |
| O navegador relata uma falha de CORS | Mantenha crossorigin="anonymous" e configure o Access-Control-Allow-Origin da CDN para a sua página, ou * para ativos públicos anônimos. |
| O script é executado sem proteção | Inspecione o elemento real em busca de metadados de integridade válidos e confirme o suporte do navegador. Metadados vazios ou não suportados não oferecem a verificação pretendida. |
