Files
movieloop-frontend/scripts/csp-inline-script-hashes.test.ts
TehRiehlDeal da32e49b0b
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
Enabling push in production failed with "Validation failed": the
browser's PushSubscription.toJSON() carries expirationTime, and it was
sent verbatim to a schema that is strict about unknown fields. The
client now sends exactly endpoint + keys. The test doubles had been
shaped like the schema rather than like a browser, which is why 1,112
tests said nothing.

The CSP hash for index.html's inline theme script was hardcoded in the
container's start-up script behind a comment asking for it to be
regenerated by hand, and claiming a test asserted it; none did. The
bytes are what the browser hashes, so an edit or a checkout with
different line endings would have silently blocked the page's own
script. The hash is now emitted by `npm run build` from the index.html
that is actually shipped, copied into the image, and read at start; the
container refuses to start without it. Verified against the built
image: header hash equals served-script hash.

For the record: a CSP console message in production is what led here,
and the served page turned out to be correct - that message was a
browser extension's injected script, which the policy is right to
refuse.
2026-09-12 10:22:14 -07:00

67 lines
2.4 KiB
TypeScript

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
cspHashToken,
cspInlineScriptHashes,
inlineScripts,
} from './csp-inline-script-hashes.mjs';
/**
* The CSP hash for index.html's inline theme script is computed from the built
* file at build time. It used to be hardcoded, guarded only by a comment
* asking whoever edited the script to regenerate it; the bytes are what the
* browser hashes, so an edit or a different line ending would have silently
* blocked the page's own script.
*/
const sha = (s: string) =>
`'sha256-${createHash('sha256').update(s, 'utf8').digest('base64')}'`;
describe('inlineScripts', () => {
it('returns the exact body of every inline script, untrimmed, and skips external ones', () => {
const html = `<html><head>
<script type="module" src="/assets/index.js"></script>
<script>
(function(){ document.documentElement.classList.add('dark'); })();
</script>
<script src=/assets/other.js></script>
<script data-x="1">console.log(1)</script>
</head></html>`;
expect(inlineScripts(html)).toEqual([
"\n (function(){ document.documentElement.classList.add('dark'); })();\n",
'console.log(1)',
]);
});
it('is empty for a page with no inline scripts', () => {
expect(inlineScripts('<script src="a.js"></script>')).toEqual([]);
});
});
describe('cspHashToken', () => {
it("produces the 'sha256-<base64>' token the browser expects", () => {
expect(cspHashToken('x')).toBe(sha('x'));
});
it('is sensitive to line endings - one of the ways a hardcoded value could drift', () => {
expect(cspHashToken('a\nb')).not.toBe(cspHashToken('a\r\nb'));
});
});
describe('cspInlineScriptHashes', () => {
it('joins one token per inline script, in document order', () => {
const html = '<script>one</script><script src="x"></script><script>two</script>';
expect(cspInlineScriptHashes(html)).toBe(`${sha('one')} ${sha('two')}`);
});
it("matches the repo's own index.html theme script", () => {
// Whatever the checkout's line endings, the token must be the hash of the
// bytes as they are; that is what the browser hashes.
const html = readFileSync('index.html', 'utf8');
const [body] = inlineScripts(html);
expect(body).toContain("localStorage.getItem('theme')");
expect(cspInlineScriptHashes(html)).toBe(sha(body ?? ''));
});
});