Skip to content

Commit 58508f7

Browse files
ralyodioclaude
andcommitted
desktop: admit the export's inline payload, which CSP was blanking
v0.2.1 fixed the assets and uncovered the next layer. A Next export carries its Flight payload in inline <script> tags, and the window's policy was script-src 'self' with no allowance for them. Before v0.2.1 that was invisible: the chunks 404ed, so React never ran and the raw prerendered HTML showed. Once the chunks loaded, React booted with its payload refused seven times over and rendered nothing — a blank window. The hashes go in the policy rather than 'unsafe-inline', which would admit any injected script, and rather than a nonce, which cannot work for HTML that is a file on disk and not regenerated per load. The policy is now built from the export the app actually serves and carried on the response itself, so the document is governed by it whether or not a webRequest listener is attached. Verified in headless Chromium under the real policy: 7 violations, 0 visible characters and no hydration before; 0 violations and the full dual-pane UI after, with the preload bridge mocked. smoke:desktop now checks that the policy admits every inline script. Assets loading and the page rendering are different facts, and the existing checks only covered the first — which is precisely how this shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CBc6KV8dL7oLS5okq15B4W
1 parent df90fd9 commit 58508f7

4 files changed

Lines changed: 176 additions & 18 deletions

File tree

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createHash } from 'node:crypto'
2+
import { describe, expect, it } from 'vitest'
3+
import { contentSecurityPolicy, inlineScriptHashes } from './csp.js'
4+
5+
const sha256 = (body: string) => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`
6+
7+
describe('inlineScriptHashes', () => {
8+
it('hashes the Flight payload scripts a Next export ships', () => {
9+
// The bug this exists to prevent: under script-src 'self' these are refused,
10+
// React boots with no payload, and the window is blank.
11+
const body = '(self.__next_f=self.__next_f||[]).push([0])'
12+
expect(inlineScriptHashes(`<html><body><script>${body}</script></body></html>`)).toEqual([sha256(body)])
13+
})
14+
15+
it('ignores scripts that load from a src', () => {
16+
const html = '<script src="/_next/static/chunks/main.js"></script>'
17+
expect(inlineScriptHashes(html)).toEqual([])
18+
})
19+
20+
it('still hashes an inline script that carries other attributes', () => {
21+
// An attribute Next adds later must not silently drop a script out of the policy.
22+
const html = '<script type="text/javascript" defer>alert(1)</script>'
23+
expect(inlineScriptHashes(html)).toEqual([sha256('alert(1)')])
24+
})
25+
26+
it('does not mistake a src on a later tag for one on this tag', () => {
27+
const html = '<script>a()</script><script src="/x.js"></script>'
28+
expect(inlineScriptHashes(html)).toEqual([sha256('a()')])
29+
})
30+
31+
it('collapses duplicates and skips empty scripts', () => {
32+
const html = '<script>x()</script><script>x()</script><script></script>'
33+
expect(inlineScriptHashes(html)).toEqual([sha256('x()')])
34+
})
35+
})
36+
37+
describe('contentSecurityPolicy', () => {
38+
it('puts the hashes in script-src', () => {
39+
const policy = contentSecurityPolicy(["'sha256-abc'", "'sha256-def'"])
40+
const directive = policy.split('; ').find((d) => d.startsWith('script-src'))
41+
expect(directive).toBe("script-src 'self' 'sha256-abc' 'sha256-def'")
42+
})
43+
44+
it('never admits inline script wholesale', () => {
45+
// 'unsafe-inline' would let an injected script run, which is the thing the
46+
// hashes exist to avoid.
47+
const directive = contentSecurityPolicy(["'sha256-abc'"])
48+
.split('; ')
49+
.find((d) => d.startsWith('script-src'))
50+
expect(directive).not.toContain('unsafe-inline')
51+
})
52+
53+
it('keeps the rest of the policy pinned to the bundle', () => {
54+
const policy = contentSecurityPolicy()
55+
expect(policy).toContain("default-src 'self'")
56+
expect(policy).toContain("object-src 'none'")
57+
expect(policy).toContain("base-uri 'none'")
58+
expect(policy).toContain("frame-src 'none'")
59+
// Inline style is what the export genuinely needs, and only style.
60+
expect(policy).toContain("style-src 'self' 'unsafe-inline'")
61+
})
62+
})

‎apps/desktop/electron/main/csp.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { createHash } from 'node:crypto'
2+
3+
/**
4+
* The inline scripts Next's export ships, as CSP source expressions.
5+
*
6+
* A static export carries its Flight payload in inline `<script>` tags
7+
* (`self.__next_f.push(...)`). Under `script-src 'self'` Chromium refuses every
8+
* one of them, so React boots with no payload and renders nothing: a blank
9+
* window. Hashing them keeps the policy strict — `'unsafe-inline'` would admit
10+
* any injected script, and a nonce cannot work here because the HTML is a file
11+
* on disk that is not regenerated per load.
12+
*
13+
* Matches any `<script>` without a `src`, whatever its other attributes, so an
14+
* attribute Next adds later cannot silently drop a script out of the policy.
15+
*/
16+
export function inlineScriptHashes(html: string): string[] {
17+
const scripts = [...html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)]
18+
const hashes = scripts
19+
.map((match) => match[1] ?? '')
20+
.filter((body) => body !== '')
21+
.map((body) => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`)
22+
return [...new Set(hashes)]
23+
}
24+
25+
/**
26+
* The window's Content-Security-Policy.
27+
*
28+
* The renderer reaches the outside world only through IPC, so everything is
29+
* pinned to the bundle's own origin; `scriptHashes` admits the export's inline
30+
* payload and nothing else.
31+
*/
32+
export function contentSecurityPolicy(scriptHashes: readonly string[] = []): string {
33+
return [
34+
"default-src 'self'",
35+
// Next's exported bundle inlines a small amount of style.
36+
"style-src 'self' 'unsafe-inline'",
37+
["script-src 'self'", ...scriptHashes].join(' '),
38+
"img-src 'self' data:",
39+
"font-src 'self' data:",
40+
// The renderer talks to the main process over IPC, not the network.
41+
"connect-src 'self'",
42+
"object-src 'none'",
43+
"frame-src 'none'",
44+
"base-uri 'none'",
45+
"form-action 'none'",
46+
].join('; ')
47+
}

‎apps/desktop/electron/main/index.ts‎

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { readFileSync } from 'node:fs'
12
import { readFile } from 'node:fs/promises'
23
import { join, normalize } from 'node:path'
34
import { fileURLToPath } from 'node:url'
45
import { app, BrowserWindow, protocol, shell } from 'electron'
56
import { contentTypeFor, resolveBundlePath } from './bundle-path.js'
7+
import { contentSecurityPolicy, inlineScriptHashes } from './csp.js'
68
import { registerIpc } from './ipc.js'
79
import { checkForUpdates } from './services/updater.js'
810
import { closeAllSessions } from './services/sessions.js'
@@ -41,15 +43,44 @@ protocol.registerSchemesAsPrivileged([
4143
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
4244
])
4345

46+
function bundleRoot(): string {
47+
return normalize(join(here, '..', '..', 'out'))
48+
}
49+
50+
/**
51+
* The policy, computed once from the export the app will actually serve.
52+
*
53+
* Read eagerly rather than per request: the hashes come from index.html, and a
54+
* policy that silently fell back to one without them would blank the window.
55+
*/
56+
let policy: string | null = null
57+
function bundlePolicy(): string {
58+
if (policy === null) {
59+
try {
60+
policy = contentSecurityPolicy(inlineScriptHashes(readFileSync(join(bundleRoot(), 'index.html'), 'utf8')))
61+
} catch {
62+
policy = contentSecurityPolicy()
63+
}
64+
}
65+
return policy
66+
}
67+
4468
/** Serves the exported renderer, and nothing outside it. */
4569
function serveBundle(): void {
46-
const root = normalize(join(here, '..', '..', 'out'))
70+
const root = bundleRoot()
4771
protocol.handle(APP_SCHEME, async (request) => {
4872
const target = resolveBundlePath(root, new URL(request.url).pathname)
4973
if (!target) return new Response('Forbidden', { status: 403 })
5074
try {
5175
const body = await readFile(target)
52-
return new Response(body, { headers: { 'content-type': contentTypeFor(target) } })
76+
return new Response(body, {
77+
headers: {
78+
'content-type': contentTypeFor(target),
79+
// Carried on the response itself, so the document is governed by the
80+
// policy whether or not a webRequest listener is attached.
81+
'content-security-policy': bundlePolicy(),
82+
},
83+
})
5384
} catch {
5485
return new Response('Not found', { status: 404 })
5586
}
@@ -94,22 +125,7 @@ function createWindow(): BrowserWindow {
94125
callback({
95126
responseHeaders: {
96127
...details.responseHeaders,
97-
'Content-Security-Policy': [
98-
[
99-
"default-src 'self'",
100-
// Next's exported bundle inlines a small amount of style.
101-
"style-src 'self' 'unsafe-inline'",
102-
"script-src 'self'",
103-
"img-src 'self' data:",
104-
"font-src 'self' data:",
105-
// The renderer talks to the main process over IPC, not the network.
106-
"connect-src 'self'",
107-
"object-src 'none'",
108-
"frame-src 'none'",
109-
"base-uri 'none'",
110-
"form-action 'none'",
111-
].join('; '),
112-
],
128+
'Content-Security-Policy': [bundlePolicy()],
113129
},
114130
})
115131
})

‎scripts/smoke-desktop.mjs‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,41 @@ function checkRendererDelivery() {
9393
console.log('ok: the renderer is served over its own scheme, not file://.')
9494
}
9595

96+
/**
97+
* The policy the window will send must admit every inline script in the export.
98+
*
99+
* Serving the assets correctly is not sufficient: a Next export carries its
100+
* Flight payload in inline `<script>` tags, and `script-src 'self'` refuses
101+
* them. That shipped in v0.2.1 — the chunks loaded, React booted with no
102+
* payload, and the window was blank. Assets loading and the page rendering are
103+
* different facts, and only this one catches the second.
104+
*/
105+
async function checkPolicyAdmitsPayload() {
106+
const { contentSecurityPolicy, inlineScriptHashes } = await import(join(desktop, 'dist-electron', 'main', 'csp.js'))
107+
const html = readFileSync(join(desktop, 'out', 'index.html'), 'utf8')
108+
const hashes = inlineScriptHashes(html)
109+
110+
if (hashes.length === 0) {
111+
console.error('FAIL: no inline scripts found in the export — the hashing no longer matches what Next emits.')
112+
console.error('A policy computed from this would blank the window.')
113+
process.exit(1)
114+
}
115+
116+
const policy = contentSecurityPolicy(hashes)
117+
const scriptSrc = policy.split('; ').find((directive) => directive.startsWith('script-src')) ?? ''
118+
const unadmitted = hashes.filter((hash) => !scriptSrc.includes(hash))
119+
120+
if (unadmitted.length > 0) {
121+
console.error(`FAIL: ${unadmitted.length} inline scripts are not admitted by script-src; the window would be blank.`)
122+
process.exit(1)
123+
}
124+
125+
console.log(`ok: the policy admits all ${hashes.length} inline payload scripts.`)
126+
}
127+
96128
await checkRendererAssets()
97129
checkRendererDelivery()
130+
await checkPolicyAdmitsPayload()
98131

99132
if (!existsSync(electron)) {
100133
console.error('electron binary not found; run pnpm install first.')

0 commit comments

Comments
 (0)