Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions apps/desktop/electron/main/csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { contentSecurityPolicy, inlineScriptHashes } from './csp.js'

const sha256 = (body: string) => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`

describe('inlineScriptHashes', () => {
it('hashes the Flight payload scripts a Next export ships', () => {
// The bug this exists to prevent: under script-src 'self' these are refused,
// React boots with no payload, and the window is blank.
const body = '(self.__next_f=self.__next_f||[]).push([0])'
expect(inlineScriptHashes(`<html><body><script>${body}</script></body></html>`)).toEqual([sha256(body)])
})

it('ignores scripts that load from a src', () => {
const html = '<script src="/_next/static/chunks/main.js"></script>'
expect(inlineScriptHashes(html)).toEqual([])
})

it('still hashes an inline script that carries other attributes', () => {
// An attribute Next adds later must not silently drop a script out of the policy.
const html = '<script type="text/javascript" defer>alert(1)</script>'
expect(inlineScriptHashes(html)).toEqual([sha256('alert(1)')])
})

it('does not mistake a src on a later tag for one on this tag', () => {
const html = '<script>a()</script><script src="/x.js"></script>'
expect(inlineScriptHashes(html)).toEqual([sha256('a()')])
})

it('collapses duplicates and skips empty scripts', () => {
const html = '<script>x()</script><script>x()</script><script></script>'
expect(inlineScriptHashes(html)).toEqual([sha256('x()')])
})
})

describe('contentSecurityPolicy', () => {
it('puts the hashes in script-src', () => {
const policy = contentSecurityPolicy(["'sha256-abc'", "'sha256-def'"])
const directive = policy.split('; ').find((d) => d.startsWith('script-src'))
expect(directive).toBe("script-src 'self' 'sha256-abc' 'sha256-def'")
})

it('never admits inline script wholesale', () => {
// 'unsafe-inline' would let an injected script run, which is the thing the
// hashes exist to avoid.
const directive = contentSecurityPolicy(["'sha256-abc'"])
.split('; ')
.find((d) => d.startsWith('script-src'))
expect(directive).not.toContain('unsafe-inline')
})

it('keeps the rest of the policy pinned to the bundle', () => {
const policy = contentSecurityPolicy()
expect(policy).toContain("default-src 'self'")
expect(policy).toContain("object-src 'none'")
expect(policy).toContain("base-uri 'none'")
expect(policy).toContain("frame-src 'none'")
// Inline style is what the export genuinely needs, and only style.
expect(policy).toContain("style-src 'self' 'unsafe-inline'")
})
})
47 changes: 47 additions & 0 deletions apps/desktop/electron/main/csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createHash } from 'node:crypto'

/**
* The inline scripts Next's export ships, as CSP source expressions.
*
* A static export carries its Flight payload in inline `<script>` tags
* (`self.__next_f.push(...)`). Under `script-src 'self'` Chromium refuses every
* one of them, so React boots with no payload and renders nothing: a blank
* window. Hashing them keeps the policy strict — `'unsafe-inline'` would admit
* any injected script, and a nonce cannot work here because the HTML is a file
* on disk that is not regenerated per load.
*
* Matches any `<script>` without a `src`, whatever its other attributes, so an
* attribute Next adds later cannot silently drop a script out of the policy.
*/
export function inlineScriptHashes(html: string): string[] {
const scripts = [...html.matchAll(/<script\b(?![^>]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi)]
const hashes = scripts
.map((match) => match[1] ?? '')
.filter((body) => body !== '')
.map((body) => `'sha256-${createHash('sha256').update(body, 'utf8').digest('base64')}'`)
return [...new Set(hashes)]
}

/**
* The window's Content-Security-Policy.
*
* The renderer reaches the outside world only through IPC, so everything is
* pinned to the bundle's own origin; `scriptHashes` admits the export's inline
* payload and nothing else.
*/
export function contentSecurityPolicy(scriptHashes: readonly string[] = []): string {
return [
"default-src 'self'",
// Next's exported bundle inlines a small amount of style.
"style-src 'self' 'unsafe-inline'",
["script-src 'self'", ...scriptHashes].join(' '),
"img-src 'self' data:",
"font-src 'self' data:",
// The renderer talks to the main process over IPC, not the network.
"connect-src 'self'",
"object-src 'none'",
"frame-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join('; ')
}
52 changes: 34 additions & 18 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join, normalize } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, BrowserWindow, protocol, shell } from 'electron'
import { contentTypeFor, resolveBundlePath } from './bundle-path.js'
import { contentSecurityPolicy, inlineScriptHashes } from './csp.js'
import { registerIpc } from './ipc.js'
import { checkForUpdates } from './services/updater.js'
import { closeAllSessions } from './services/sessions.js'
Expand Down Expand Up @@ -41,15 +43,44 @@ protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } },
])

function bundleRoot(): string {
return normalize(join(here, '..', '..', 'out'))
}

/**
* The policy, computed once from the export the app will actually serve.
*
* Read eagerly rather than per request: the hashes come from index.html, and a
* policy that silently fell back to one without them would blank the window.
*/
let policy: string | null = null
function bundlePolicy(): string {
if (policy === null) {
try {
policy = contentSecurityPolicy(inlineScriptHashes(readFileSync(join(bundleRoot(), 'index.html'), 'utf8')))
} catch {
policy = contentSecurityPolicy()
}
}
return policy
}

/** Serves the exported renderer, and nothing outside it. */
function serveBundle(): void {
const root = normalize(join(here, '..', '..', 'out'))
const root = bundleRoot()
protocol.handle(APP_SCHEME, async (request) => {
const target = resolveBundlePath(root, new URL(request.url).pathname)
if (!target) return new Response('Forbidden', { status: 403 })
try {
const body = await readFile(target)
return new Response(body, { headers: { 'content-type': contentTypeFor(target) } })
return new Response(body, {
headers: {
'content-type': contentTypeFor(target),
// Carried on the response itself, so the document is governed by the
// policy whether or not a webRequest listener is attached.
'content-security-policy': bundlePolicy(),
},
})
} catch {
return new Response('Not found', { status: 404 })
}
Expand Down Expand Up @@ -94,22 +125,7 @@ function createWindow(): BrowserWindow {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
// Next's exported bundle inlines a small amount of style.
"style-src 'self' 'unsafe-inline'",
"script-src 'self'",
"img-src 'self' data:",
"font-src 'self' data:",
// The renderer talks to the main process over IPC, not the network.
"connect-src 'self'",
"object-src 'none'",
"frame-src 'none'",
"base-uri 'none'",
"form-action 'none'",
].join('; '),
],
'Content-Security-Policy': [bundlePolicy()],
},
})
})
Expand Down
33 changes: 33 additions & 0 deletions scripts/smoke-desktop.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,41 @@ function checkRendererDelivery() {
console.log('ok: the renderer is served over its own scheme, not file://.')
}

/**
* The policy the window will send must admit every inline script in the export.
*
* Serving the assets correctly is not sufficient: a Next export carries its
* Flight payload in inline `<script>` tags, and `script-src 'self'` refuses
* them. That shipped in v0.2.1 — the chunks loaded, React booted with no
* payload, and the window was blank. Assets loading and the page rendering are
* different facts, and only this one catches the second.
*/
async function checkPolicyAdmitsPayload() {
const { contentSecurityPolicy, inlineScriptHashes } = await import(join(desktop, 'dist-electron', 'main', 'csp.js'))
const html = readFileSync(join(desktop, 'out', 'index.html'), 'utf8')
const hashes = inlineScriptHashes(html)

if (hashes.length === 0) {
console.error('FAIL: no inline scripts found in the export — the hashing no longer matches what Next emits.')
console.error('A policy computed from this would blank the window.')
process.exit(1)
}

const policy = contentSecurityPolicy(hashes)
const scriptSrc = policy.split('; ').find((directive) => directive.startsWith('script-src')) ?? ''
const unadmitted = hashes.filter((hash) => !scriptSrc.includes(hash))

if (unadmitted.length > 0) {
console.error(`FAIL: ${unadmitted.length} inline scripts are not admitted by script-src; the window would be blank.`)
process.exit(1)
}

console.log(`ok: the policy admits all ${hashes.length} inline payload scripts.`)
}

await checkRendererAssets()
checkRendererDelivery()
await checkPolicyAdmitsPayload()

if (!existsSync(electron)) {
console.error('electron binary not found; run pnpm install first.')
Expand Down
Loading