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
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.
67 lines
2.4 KiB
TypeScript
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 ?? ''));
|
|
});
|
|
});
|