fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
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.
This commit is contained in:
TehRiehlDeal committed 2026-09-12 10:22:14 -07:00
1 parent 4aceca1790
commit da32e49b0b
13 files changed
+333 -20

No files matched your search

+1
View File
@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
build
*.local *.local
# Editor directories and files # Editor directories and files
+31
View File
@@ -9,6 +9,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.2.3] - 2026-09-12
The first production deploy of the push opt-in.
### Fixed
- **Enabling push notifications no longer fails with "Validation failed".**
The browser's `PushSubscription.toJSON()` includes `expirationTime`, and it
was being sent verbatim to a server schema that is deliberately strict about
unknown fields. The client now sends exactly what the server accepts -
`endpoint` and `keys` - and refuses an incomplete subscription rather than
sending it. (The server also accepts the field now; either side alone fixes
it.) Nothing had ever called this path before 2.2.0, and the test doubles
were shaped like the schema rather than like a browser.
### Changed
- **The CSP hash for the inline theme script is computed at build time** from
the `index.html` that is actually shipped, copied into the image and read at
start, instead of being hardcoded in the container's start-up script behind
a comment asking for it to be regenerated by hand. The bytes are what the
browser hashes, so an edit to the script - or a checkout with different line
endings - would have silently blocked the page's own theme script. The
container now refuses to start without the file rather than serve a policy
that blocks its own page. Verified against the built image: the header's
hash equals the hash of the served script. (Investigating a CSP console
message in production is what surfaced this; the served page turned out to
be correct, and that message came from a browser extension's injected
script, which the policy is right to refuse.)
- Every text file is checked out with LF on every platform (`.gitattributes`).
## [2.2.2] - 2026-09-12 ## [2.2.2] - 2026-09-12
Found by playing the app end to end against a clean-install development stack Found by playing the app end to end against a clean-install development stack
+4
View File
@@ -37,6 +37,10 @@ USER root
RUN apk upgrade --no-cache RUN apk upgrade --no-cache
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
# The CSP hash tokens for index.html's inline scripts, produced by `npm run
# build` from the shipped file. Outside the web root: it belongs to the
# server configuration, not the site.
COPY --from=build /app/build/csp-inline-script-hashes /etc/nginx/csp-inline-script-hashes
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh COPY docker/40-render-config.sh /docker-entrypoint.d/40-render-config.sh
+1 -1
View File
@@ -113,7 +113,7 @@ Opt-in from **Profile → Notifications**. The service worker is registered at s
## Production image ## Production image
`Dockerfile` has three stages: `dev` (Vite), `build`, and `production` — `nginxinc/nginx-unprivileged` serving `dist/` on **port 8080** as uid 101. `docker/40-render-config.sh` runs at container start to write `/config.js` and the security-header snippet (CSP with the API origin in `connect-src`, HSTS, frame and MIME protections) from `API_URL`. `nginx.conf` repeats the headers in every `location`, since nginx's `add_header` is not inherited. `Dockerfile` has three stages: `dev` (Vite), `build`, and `production` — `nginxinc/nginx-unprivileged` serving `dist/` on **port 8080** as uid 101. `docker/40-render-config.sh` runs at container start to write `/config.js` and the security-header snippet (CSP with the API origin in `connect-src`, HSTS, frame and MIME protections) from `API_URL`. The CSP allows `index.html`'s inline theme script by hash; `npm run build` computes that hash from the built file (`scripts/csp-inline-script-hashes.mjs`) and the image carries it, so editing the script needs no manual step. `nginx.conf` repeats the headers in every `location`, since nginx's `add_header` is not inherited.
## Releasing ## Releasing
+16 -4
View File
@@ -55,9 +55,20 @@ connect_src="'self' ${api_origin} ${ws_origin}"
# 'unsafe-inline' out of the policy - with 'unsafe-inline' a CSP buys almost # 'unsafe-inline' out of the policy - with 'unsafe-inline' a CSP buys almost
# nothing against injected script, which is the main thing it is here to stop. # nothing against injected script, which is the main thing it is here to stop.
# #
# If that script is edited, this hash must be regenerated or the page will not # The hash tokens are computed at build time from the index.html that is
# apply its theme. It is asserted by the header test in the repo. # actually shipped (scripts/csp-inline-script-hashes.mjs, run by `npm run
theme_script_hash="sha256-8evBcs4UKW13abYRRwTcDjBEK5h9IWhKnpEbZ+Mv4nQ=" # build`) and copied into the image by the Dockerfile. A hardcoded value used
# to live here, guarded by a comment asking for it to be regenerated by hand
# when the script changed; the bytes are what the browser hashes, so an edit
# or a different line ending would have silently blocked the page's own
# script. If the file is missing the build is broken, and refusing to start is
# better than serving a CSP that blocks the page.
hashes_file=/etc/nginx/csp-inline-script-hashes
if [ ! -s "$hashes_file" ]; then
echo "[config] ERROR: $hashes_file is missing or empty; the image was built without the CSP hash step" >&2
exit 1
fi
script_src_hashes=$(tr -d '\n\r' < "$hashes_file")
mkdir -p /etc/nginx/snippets mkdir -p /etc/nginx/snippets
cat > /etc/nginx/snippets/security-headers.conf <<EOF cat > /etc/nginx/snippets/security-headers.conf <<EOF
@@ -66,8 +77,9 @@ add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always; add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; object-src 'none'; script-src 'self' '${theme_script_hash}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:; connect-src ${connect_src}" always; add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; object-src 'none'; script-src 'self' ${script_src_hashes}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:; connect-src ${connect_src}" always;
EOF EOF
echo "[config] API_URL=${API_URL} -> /config.js" echo "[config] API_URL=${API_URL} -> /config.js"
echo "[config] CSP connect-src: ${connect_src}" echo "[config] CSP connect-src: ${connect_src}"
echo "[config] CSP script-src hashes: ${script_src_hashes}"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"version": "2.2.2", "version": "2.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"version": "2.2.2", "version": "2.2.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
+2 -2
View File
@@ -1,13 +1,13 @@
{ {
"name": "movie-loop-frontend", "name": "movie-loop-frontend",
"private": true, "private": true,
"version": "2.2.2", "version": "2.2.3",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Kevin Riehl", "author": "Kevin Riehl",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build && node scripts/csp-inline-script-hashes.mjs",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
+69
View File
@@ -0,0 +1,69 @@
// Emits the CSP `script-src` hash tokens for every inline script in the built
// index.html, so the container's Content-Security-Policy can allow exactly
// those scripts and nothing else.
//
// The theme bootstrap script is inline on purpose: it runs before first paint
// so the page does not flash the wrong theme, and moving it to a file would
// mean either a blocking request or the flash coming back. Allowing it by
// hash keeps 'unsafe-inline' out of the policy, which is the whole point of
// having one.
//
// The hash used to be hardcoded in docker/40-render-config.sh, with a comment
// saying it had to be regenerated by hand whenever the script changed and that
// a test asserted it. No test did. Any edit to the script - or a checkout with
// different line endings, since the bytes are what is hashed - would have
// silently blocked the page's own theme script, with nothing but a browser
// console message to show for it. Computing it here, from the bytes that are
// actually shipped, removes every way it could drift.
//
// Usage: node scripts/csp-inline-script-hashes.mjs [distDir] [outFile]
// default: dist -> build/csp-inline-script-hashes
// Output: one line, e.g. 'sha256-…' 'sha256-…', ready to splice into script-src.
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* The content of every `<script>` element with no `src`, exactly as the
* browser will hash it: the text between the tags, untrimmed.
*/
export function inlineScripts(html) {
const out = [];
const re = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
let m;
while ((m = re.exec(html)) !== null) {
const attrs = m[1];
if (/\bsrc\s*=/i.test(attrs)) continue;
out.push(m[2]);
}
return out;
}
export function cspHashToken(source) {
return `'sha256-${createHash('sha256').update(source, 'utf8').digest('base64')}'`;
}
export function cspInlineScriptHashes(html) {
return inlineScripts(html).map(cspHashToken).join(' ');
}
function main(argv) {
const distDir = resolve(argv[0] ?? 'dist');
const outFile = resolve(argv[1] ?? 'build/csp-inline-script-hashes');
const html = readFileSync(resolve(distDir, 'index.html'), 'utf8');
const tokens = cspInlineScriptHashes(html);
if (tokens.length === 0) {
// Not an error - a page with no inline scripts needs no hashes - but the
// theme script is expected, so say so where the build log is read.
console.warn('[csp] no inline scripts found in index.html');
}
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, tokens + '\n');
console.log(`[csp] inline script hashes -> ${outFile}: ${tokens || '(none)'}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2));
}
+66
View File
@@ -0,0 +1,66 @@
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 ?? ''));
});
});
+94
View File
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The push API module. The important case is the payload shape: the browser's
* PushSubscription.toJSON() includes `expirationTime`, and the server schema
* is strict, so sending toJSON() verbatim was a 400 "Validation failed" - the
* first time anyone pressed the button in production.
*/
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiDelete = vi.fn();
vi.mock('./client', () => ({
default: {
get: (...a: unknown[]) => apiGet(...a),
post: (...a: unknown[]) => apiPost(...a),
delete: (...a: unknown[]) => apiDelete(...a),
},
}));
const push = await import('./push');
const browserSubscription = (
json: Partial<PushSubscriptionJSON> = {},
): PushSubscription =>
({
toJSON: () => ({
endpoint: 'https://push.example/abc',
expirationTime: null,
keys: { p256dh: 'p256', auth: 'auth' },
...json,
}),
}) as unknown as PushSubscription;
beforeEach(() => {
vi.clearAllMocks();
});
describe('toPushSubscriptionPayload', () => {
it('keeps endpoint and keys and drops expirationTime', () => {
expect(push.toPushSubscriptionPayload(browserSubscription())).toEqual({
endpoint: 'https://push.example/abc',
keys: { p256dh: 'p256', auth: 'auth' },
});
});
it('drops a non-null expirationTime too', () => {
expect(
push.toPushSubscriptionPayload(
browserSubscription({ expirationTime: 1_700_000_000_000 }),
),
).not.toHaveProperty('expirationTime');
});
it('refuses an incomplete subscription rather than sending it', () => {
expect(() =>
push.toPushSubscriptionPayload(browserSubscription({ keys: { p256dh: 'p' } })),
).toThrow(/incomplete push subscription/);
expect(() =>
push.toPushSubscriptionPayload(browserSubscription({ endpoint: undefined })),
).toThrow(/incomplete push subscription/);
});
});
describe('subscribeToPush', () => {
it('posts only the fields the server accepts', async () => {
apiPost.mockResolvedValue({ data: { id: 's1' } });
await expect(push.subscribeToPush(browserSubscription())).resolves.toEqual({
id: 's1',
});
expect(apiPost).toHaveBeenCalledWith('/notifications/push/subscribe', {
endpoint: 'https://push.example/abc',
keys: { p256dh: 'p256', auth: 'auth' },
});
});
});
describe('getVapidKey / unsubscribeFromPush', () => {
it('reads the key', async () => {
apiGet.mockResolvedValue({ data: { key: 'BAbc' } });
await expect(push.getVapidKey()).resolves.toBe('BAbc');
expect(apiGet).toHaveBeenCalledWith('/notifications/push/vapid-key');
});
it('sends the endpoint as the delete body', async () => {
apiDelete.mockResolvedValue({ data: { success: true } });
await expect(push.unsubscribeFromPush('https://push.example/abc')).resolves.toEqual({
success: true,
});
expect(apiDelete).toHaveBeenCalledWith('/notifications/push/unsubscribe', {
data: { endpoint: 'https://push.example/abc' },
});
});
});
+34 -5
View File
@@ -1,18 +1,47 @@
import apiClient from './client'; import apiClient from './client';
export async function getVapidKey(): Promise<string | null> { export async function getVapidKey(): Promise<string | null> {
const { data } = await apiClient.get('/notifications/push/vapid-key'); const { data } = await apiClient.get<{ key: string | null }>(
'/notifications/push/vapid-key',
);
return data.key; return data.key;
} }
/**
* What the server's PushSubscription schema accepts. The browser's
* `PushSubscription.toJSON()` also carries `expirationTime`, which the strict
* schema rejected with "Validation failed" - the first time push was actually
* enabled in production, since nothing had ever called it before WP-17.
*/
export interface PushSubscriptionPayload {
endpoint: string;
keys: { p256dh: string; auth: string };
}
export function toPushSubscriptionPayload(
subscription: PushSubscription,
): PushSubscriptionPayload {
const json = subscription.toJSON();
const p256dh = json.keys?.p256dh;
const auth = json.keys?.auth;
if (!json.endpoint || !p256dh || !auth) {
throw new Error('The browser returned an incomplete push subscription.');
}
return { endpoint: json.endpoint, keys: { p256dh, auth } };
}
export async function subscribeToPush(subscription: PushSubscription) { export async function subscribeToPush(subscription: PushSubscription) {
const { data } = await apiClient.post('/notifications/push/subscribe', subscription.toJSON()); const { data } = await apiClient.post<{ id: string }>(
'/notifications/push/subscribe',
toPushSubscriptionPayload(subscription),
);
return data; return data;
} }
export async function unsubscribeFromPush(endpoint: string) { export async function unsubscribeFromPush(endpoint: string) {
const { data } = await apiClient.delete('/notifications/push/unsubscribe', { const { data } = await apiClient.delete<{ success: boolean }>(
data: { endpoint }, '/notifications/push/unsubscribe',
}); { data: { endpoint } },
);
return data; return data;
} }
+12 -5
View File
@@ -50,7 +50,13 @@ function fakeSubscription(endpoint = 'https://push.example/abc'): FakeSubscripti
unsubscribe.mockResolvedValue(true); unsubscribe.mockResolvedValue(true);
return { return {
endpoint, endpoint,
toJSON: () => ({ endpoint, keys: { p256dh: 'p', auth: 'a' } }), // What a real browser returns: expirationTime is always present (usually
// null). The server schema is strict and does not accept it.
toJSON: () => ({
endpoint,
expirationTime: null,
keys: { p256dh: 'p', auth: 'a' },
}),
unsubscribe, unsubscribe,
}; };
} }
@@ -152,10 +158,11 @@ describe('subscribeToPush', () => {
expect(options.userVisibleOnly).toBe(true); expect(options.userVisibleOnly).toBe(true);
// base64url decoded: 'BAbc_-12' -> 6 bytes // base64url decoded: 'BAbc_-12' -> 6 bytes
expect(new Uint8Array(options.applicationServerKey as ArrayBuffer)).toHaveLength(6); expect(new Uint8Array(options.applicationServerKey as ArrayBuffer)).toHaveLength(6);
expect(apiPost).toHaveBeenCalledWith( // Only what the server accepts: no expirationTime.
'/notifications/push/subscribe', expect(apiPost).toHaveBeenCalledWith('/notifications/push/subscribe', {
sub.toJSON(), endpoint: 'https://push.example/abc',
); keys: { p256dh: 'p', auth: 'a' },
});
}); });
it('reuses an existing registration instead of registering again', async () => { it('reuses an existing registration instead of registering again', async () => {
+1 -1
View File
@@ -70,7 +70,7 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'], include: ['src/**/*.{test,spec}.{ts,tsx}', 'scripts/**/*.test.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'json-summary'], reporter: ['text', 'json-summary'],