Vérifier l’intégrité d’un CDN avec SHA-384 et SRI
Pour lier un script CDN à des octets de confiance, générez une empreinte SHA-384 à partir du fichier
de la version approuvée et placez-la dans l’attribut integrity du script. Ce tutoriel vous fournit une
commande de hachage qui échoue en cas d’entrée manquante, ainsi qu’une démo locale dans le navigateur
qui prouve que les scripts modifiés ne peuvent pas s’exécuter.
Bien que sha384sum calcule le bon algorithme, sa sortie hexadécimale nécessite un encodage différent
pour l’intégrité des sous-ressources (Subresource Integrity, SRI).
Comprendre l’intégrité des sous-ressources (SRI)
Le navigateur télécharge le script, calcule l’empreinte de son contenu et compare le résultat à la valeur attendue dans votre HTML avant de l’exécuter. Une non-correspondance bloque l’exécution. L’empreinte doit provenir d’un build de confiance ou d’une version vérifiée de manière indépendante : hacher une réponse CDN compromise et accepter cette valeur reviendrait à approuver le remplacement. Votre HTML fait aussi partie de ce périmètre de confiance ; une personne capable de modifier à la fois le script et son empreinte attendue peut contourner la vérification.
SRI prend en charge SHA-256, SHA-384 et SHA-512. SHA-384 constitue une base utile, avec une empreinte plus courte que SHA-512. Si vous fournissez plusieurs algorithmes, les navigateurs utilisent le plus robuste qu’ils prennent en charge, sans se rabattre sur un algorithme plus faible après une non-correspondance. Consultez la spécification SRI.
Générer une empreinte en ligne de commande
Utilisez Bash et OpenSSL pour la commande, ainsi que Node.js pour les serveurs locaux ci-dessous. Cet exemple a été testé sous Linux avec Bash 5.3.15, OpenSSL 3.6.4, Node.js 26.8.1 et Chromium 152. Il ne nécessite aucune installation de paquet ni aucun compte CDN. Enregistrez les fichiers d’exemple dans un nouveau répertoire vide.
Enregistrez ceci sous demo.js. Il tient lieu du fichier de la version approuvée que vous obtiendriez
normalement de votre build ou de votre éditeur :
document.getElementById('status').textContent = 'Trusted script executed'
Enregistrez ce qui suit sous sri.sh. Il affiche une valeur SRI en cas de succès, écrit des
diagnostics sur stderr en cas d’échec et ne modifie jamais le fichier d’entrée :
#!/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
L’option -binary d’OpenSSL produit les octets de
l’empreinte ; base64 -A les encode sans sauts de ligne.
N’encodez pas en base64 le texte affiché par sha384sum : ce texte représente l’empreinte en
hexadécimal.
La vérification du code de sortie compte autant que l’encodage. Un pipeline tel que
cat missing.js | openssl … peut hacher un flux vide après l’échec de cat. Ici, le pipefail de Bash et
l’affectation vérifiée empêchent les lectures ou les commandes OpenSSL en échec d’afficher une valeur
exploitable. La redirection d’entrée évite aussi que des noms de fichiers commençant par un tiret
soient interprétés comme des options OpenSSL. Un fichier vide lisible est une entrée valide et
possède sa propre empreinte ; un fichier manquant est une erreur.
Exécutez le script directement avec Bash :
bash sri.sh ./demo.js
La sortie commence par sha384-, suivi de 64 caractères base64. Les caractères d’espacement et les
fins de ligne de demo.js influent sur le résultat : hachez donc exactement les octets que vous
servirez.
Intégrer SRI dans votre HTML ou JSX
Pour un script classique chargé depuis une autre origine, définissez à la fois integrity et
crossorigin="anonymous". Le serveur de ressources doit aussi envoyer un en-tête Access-Control-Allow-Origin approprié. L’absence de
l’une ou l’autre condition peut bloquer le script même lorsque ses octets correspondent.
MDN explique l’exigence CORS.
En JSX, l’attribut s’écrit crossOrigin.
Enregistrez ceci sous server.ts. Il sert une page et son script sur deux ports de bouclage (loopback)
différents, de sorte que le navigateur les traite comme des origines différentes. Le système
d’exploitation choisit des ports disponibles. L’empreinte provient du shell, tandis que la réponse
modifiée conserve délibérément l’empreinte attendue d’origine.
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)
})
Lancez-le depuis le répertoire contenant les trois fichiers. Le && empêche le démarrage si le
hachage échoue ; aucune de ces commandes n’écrit de fichier de sortie ni n’écrase vos exemples :
expected_sri=$(bash sri.sh ./demo.js) &&
SRI="$expected_sri" node server.ts
Ouvrez l’URL affichée dans votre navigateur. La page Trusted affiche Trusted script executed. Chacun des autres liens laisse Waiting for script à l’écran :
- Changed bytes sert un commentaire ajouté. Même cette modification anodine provoque une non-correspondance d’empreinte et empêche l’exécution de l’ensemble du script.
- Missing CORS header sert les octets d’origine sans l’autorisation du serveur de ressources pour les lire depuis une autre origine.
- Missing crossorigin omet l’attribut CORS de l’élément, alors que le serveur de ressources envoie toujours son en-tête d’autorisation.
Ouvrez la console du navigateur pour distinguer une non-correspondance d’intégrité d’une erreur
CORS. Une réponse HTTP réussie ne suffit pas à établir que le script s’est exécuté. Arrêtez les deux
serveurs avec Ctrl+C. La démo lit demo.js une seule fois au démarrage et ne stocke rien ; son
point de terminaison d’alerte n’est qu’un récepteur local pour l’exemple de surveillance facultatif
ci-dessous. Utilisez HTTPS pour votre vraie page et votre CDN. SRI ne protège pas le HTML livré via
une connexion qu’un attaquant peut réécrire.
Générer des empreintes dans le navigateur avec l’API Web Crypto
Pour le diagnostic, collez cette fonction dans la console de développement de la page de démo. Elle calcule l’empreinte des octets récupérés et rejette les erreurs HTTP afin de ne pas lire une page d’erreur comme 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)}`
}
Sur la page Trusted, exécutez :
const resource = document.querySelector('script[integrity]')
console.log(await generateSRIHash(resource.src) === resource.integrity)
Cela affiche true. La même comparaison sur la page
Changed bytes affiche false. Conservez l’empreinte attendue
issue de la version de confiance ; ne la remplacez pas par ce que cette fonction récupère. Cette
récupération distincte ne peut pas prouver quels octets une requête de script antérieure a exécutés.
C’est l’attribut integrity de l’élément qui impose cette vérification.
crypto.subtle.digest()
nécessite un contexte sécurisé, tel que HTTPS ou cette démo en loopback. Elle met toute la ressource
en mémoire tampon. Les fonctions utilitaires JavaScript facultatives nécessitent aussi
AbortSignal.timeout(),
qui limite chaque récupération, lecture du corps comprise, à dix secondes de temps actif. Ce délai
peut s’interrompre lorsqu’un document est suspendu. Vérifiez la prise en charge de ces API
séparément de celle de SRI si vous ciblez d’anciens navigateurs ; les navigateurs qui ne prennent
pas en charge SRI n’appliquent pas l’attribut.
Charger des scripts dynamiquement
Pour un script ajouté après le chargement de la page, définissez les propriétés d’intégrité et CORS avant de l’insérer. Collez cette fonction utilitaire dans la même console, ou incluez-la dans le JavaScript de votre application :
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)
})
}
Mettre en place des solutions de repli
Utilisez un miroir de confiance identique octet pour octet et conservez la même empreinte attendue pour le repli. Ce code tente une seule fois la source de secours et propage son échec :
async function loadWithFallback(primary, backup, integrity) {
try {
await loadScript(primary, integrity)
} catch {
console.warn(`Primary failed, switching to ${backup}`)
await loadScript(backup, integrity)
}
}
Avec les deux fonctions utilitaires définies et resource issu de la comparaison ci-dessus, ce code
déclenche la requête principale, qui échoue, puis un chargement vérifié depuis la ressource d’origine
de la démo :
await loadWithFallback(
new URL('/changed.js', resource.src).href,
new URL('/demo.js', resource.src).href,
resource.integrity,
)
Surveiller les ressources CDN en production
Des vérifications périodiques peuvent signaler des changements par rapport à une empreinte de
confiance, mais les onglets du navigateur peuvent se fermer ou être suspendus. Utilisez une tâche
planifiée indépendante pour la surveillance opérationnelle. Pour un diagnostic dans le navigateur,
cette fonction utilitaire maintient une seule interrogation à la fois, isole les échecs par
ressource, vérifie le statut HTTP des alertes et permet d’arrêter puis de relancer l’interrogation.
stop() empêche les interrogations futures ; une interrogation en cours se termine.
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
}
}
Après avoir défini generateSRIHash, resource et la classe, essayez ceci dans la console de la démo :
const monitor = new SRIMonitor(1000)
monitor.add(new URL('/changed.js', resource.src).href, resource.integrity)
monitor.start()
// Run monitor.stop() when finished.
Le navigateur journalise une non-correspondance et le serveur affiche Local SRI alert received. Le récepteur en loopback accuse réception des alertes puis les ignore ; il ne fournit ni authentification, ni stockage, ni notifications externes. En production, fournissez un point de terminaison authentifié et soumis à une limitation de débit, et évitez les URL contenant des identifiants de connexion dans les rapports. Une erreur réseau ou un échec CORS constitue une vérification en échec, pas une preuve que le contenu a changé.
Automatiser SRI dans votre pipeline CI/CD
Générez les empreintes après l’étape finale de minification de votre build de confiance, avant le
rendu du HTML.
Exécutez sri.sh pour chaque script ou feuille de style prévu et faites échouer le build en cas de
code de sortie non nul ; faites-le aussi échouer si la liste des ressources attendues est vide.
Stockez chaque nom de fichier et sa valeur SRI dans votre manifeste de build, puis déployez ensemble
les ressources de ce manifeste et le HTML rendu. Cette intégration dépend de votre système de build ;
la démo locale ci-dessus n’installe pas de flux de travail CI.
Préférez des URL de ressources versionnées aux alias modifiables tels que latest. Une mise à jour
intentionnelle d’une dépendance exige de relire la nouvelle version, puis d’utiliser une nouvelle URL
de ressource et sa nouvelle empreinte attendue. Ne faites pas en sorte qu’un échec de vérification
d’intégrité mette automatiquement à jour la valeur attendue.
Résoudre les problèmes courants
| Symptôme | Points à vérifier |
|---|---|
| La commande de hachage échoue | Vérifiez le chemin, les permissions et l’installation d’OpenSSL. Une entrée manquante ne doit pas produire l’empreinte d’un fichier vide. |
| Le navigateur signale une non-correspondance d’intégrité | Comparez la réponse avec la version approuvée ou l’artefact de build. Examinez tout changement inattendu ; ne mettez à jour l’empreinte qu’après avoir approuvé les nouveaux octets. |
| L’empreinte locale diffère des octets du CDN | Vérifiez la minification, les bannières injectées et les fins de ligne. La compression HTTP ordinaire est décodée avant la vérification d’intégrité. |
| Le navigateur signale un échec CORS | Conservez crossorigin="anonymous" et configurez l’en-tête Access-Control-Allow-Origin du CDN pour votre page, ou * pour les ressources publiques anonymes. |
| Le script s’exécute sans protection | Inspectez l’élément réel pour vérifier la validité de ses métadonnées d’intégrité et confirmez la prise en charge par le navigateur. Des métadonnées vides ou non prises en charge n’assurent pas la vérification prévue. |
