Background
I run a small browser-based tool that generates Subresource Integrity values.
https://hashitosystem.com/en/tools/srihash/
You drop a file or paste some text, and it prints sha384-... plus a ready-made <script> tag.
While re-reading its code, I noticed two ways a user can get a perfectly "correct" hash that the browser still rejects.
Neither is a bug in the hashing itself, which is why they are easy to miss.
What SRI checks
Subresource Integrity (SRI) is the integrity attribute on <script> and <link> tags.
It holds a hash of the file you expect, for example sha384- followed by the Base64 of the SHA-384 digest.
When the browser downloads the file, it hashes the bytes it actually received and compares.
If they differ, the script or stylesheet is not applied.
The point is to protect you when the file comes from someone else's server, typically a CDN (a content delivery network that serves copies of popular libraries from many locations).
If the CDN copy is swapped for a malicious one, the hash no longer matches and the browser refuses it.
The spec is here:
The important word is bytes.
SRI does not know about "text" or "the same code".
One different byte means a different hash.
Trap 1: pasted text is not the file
The tool has a textarea for pasting code.
Its handler takes the textarea value, encodes it as UTF-8, and hashes it with WebCrypto:
// from the tool: called on every input event of the textarea
function setFromText(){
var text = $("textInput").value;
lastBuffer = new TextEncoder().encode(text).buffer;
lastLabel = T.pasted;
render();
}
render() passes lastBuffer to crypto.subtle.digest() for each selected algorithm and Base64-encodes the result.
That part is fine.
The problem is textInput.value.
The HTML standard says the textarea's "API value", which is what the value property returns, is normalized so that line breaks are LF (\n) characters.
https://html.spec.whatwg.org/multipage/form-elements.html#the-textarea-element
So if your file on the server uses CRLF (carriage return + line feed, \r\n, the Windows default) line endings, and you copy its contents into the textarea, the tool hashes an LF version of the file.
The server keeps sending the CRLF version.
The hashes can never match.
I reproduced it with Node.js v24.11.1 and OpenSSL 3.2.3 on Windows.
The script below builds the same two lines of JavaScript with LF and with CRLF endings and hashes both.
It also contains a small checker, matches(), which I use for the second trap.
// sri.js - compute and check Subresource Integrity values with Node's crypto
const crypto = require('crypto');
function sri(alg, bytes) {
return alg + '-' + crypto.createHash(alg).update(bytes).digest('base64');
}
// Same idea as the spec's "get the strongest metadata" + "do bytes match":
// only the strongest algorithm present in the attribute is compared.
const ORDER = ['sha256', 'sha384', 'sha512'];
function matches(integrity, bytes) {
const items = integrity.trim().split(/\s+/)
.map((t) => /^(sha256|sha384|sha512)-(.+)$/.exec(t))
.filter(Boolean)
.map((m) => ({ alg: m[1], value: m[2] }));
if (!items.length) return true; // nothing usable -> no check
const top = Math.max(...items.map((i) => ORDER.indexOf(i.alg)));
return items
.filter((i) => ORDER.indexOf(i.alg) === top)
.some((i) => sri(i.alg, bytes) === i.alg + '-' + i.value);
}
const lf = Buffer.from('console.log("hello");\nconsole.log("world");\n');
const crlf = Buffer.from('console.log("hello");\r\nconsole.log("world");\r\n');
console.log('LF ', sri('sha384', lf));
console.log('CRLF ', sri('sha384', crlf));
const good256 = sri('sha256', crlf);
const bad512 = 'sha512-' + Buffer.alloc(64).toString('base64');
console.log('sha256 ok only ->', matches(good256, crlf));
console.log('sha256 ok + sha512 wrong ->', matches(good256 + ' ' + bad512, crlf));
console.log('LF hash vs CRLF file ->', matches(sri('sha384', lf), crlf));
Running it gives this:
$ node sri.js
LF sha384-PodCjN/UtI3bmhRbzuh2f8MreRrb6t3UGdbe/lAL2iaSiB0KG97YrjGiM+taFKlK
CRLF sha384-vE2zXEYpHh/j7vNH9alyBTCnXDG/7MPztb/K7edllO3K5Fgi1AvFAQpqyzISrEIJ
sha256 ok only -> true
sha256 ok + sha512 wrong -> false
LF hash vs CRLF file -> false
The two files look identical in an editor, but the hashes are completely different.
The last line is the textarea situation: an LF hash checked against a CRLF file fails.
I also checked the same two files with OpenSSL, which is the command MDN and most READMEs show, and the output agrees with Node:
$ printf 'console.log("hello");\r\nconsole.log("world");\r\n' > app.js
$ echo "sha384-$(openssl dgst -sha384 -binary app.js | openssl base64 -A)"
sha384-vE2zXEYpHh/j7vNH9alyBTCnXDG/7MPztb/K7edllO3K5Fgi1AvFAQpqyzISrEIJ
This bites hardest on Windows, where Git may check files out with CRLF while the deployed copy (or the npm package on the CDN) has LF.
Hashing the file on your disk can be just as wrong as pasting it, if your disk copy is not byte-for-byte what gets served.
The fix is boring: hash the file that is actually served.
Download it (curl -o lib.js {URL}) and hash that, or use the tool's file input, which reads the file with FileReader.readAsArrayBuffer() and never goes through a textarea.
Pasting is fine for a quick look, but I now treat a pasted-text hash as a draft, not the value to ship.
Trap 2: only the strongest hash counts
The tool lets you tick SHA-256, SHA-384 and SHA-512 together and joins the results with spaces.
Multiple values in one attribute are allowed, and it is tempting to read them as "any of these may match".
That is not what the spec says.
Its "get the strongest metadata from set" step keeps only the entries using the strongest algorithm (the order is sha256 < sha384 < sha512), and only those are compared with the response.
Weaker entries are simply ignored.
The second and third lines of the output above show the effect, using matches(), which follows those two spec steps.
A correct sha256-... alone passes.
The same correct sha256-... next to an outdated sha512-... fails, because the browser only looks at the sha512 one.
This happens in real life when you update a library, regenerate one hash, and forget the other.
Where you do want multiple values is several entries of the same algorithm, for example two sha384 hashes for two acceptable builds of a file; any match among those passes.
What I would do
Generate SRI values from the exact bytes your users will download, not from a copy in your editor or clipboard.
Use one algorithm (sha384 is the common choice), and when you update the file, replace the whole integrity value instead of appending to it.
Those two habits remove both traps.
This article is about my own side project. It was written with AI assistance.
Top comments (0)