|
| 1 | +import { readFileSync } from 'node:fs' |
1 | 2 | import { readFile } from 'node:fs/promises' |
2 | 3 | import { join, normalize } from 'node:path' |
3 | 4 | import { fileURLToPath } from 'node:url' |
4 | 5 | import { app, BrowserWindow, protocol, shell } from 'electron' |
5 | 6 | import { contentTypeFor, resolveBundlePath } from './bundle-path.js' |
| 7 | +import { contentSecurityPolicy, inlineScriptHashes } from './csp.js' |
6 | 8 | import { registerIpc } from './ipc.js' |
7 | 9 | import { checkForUpdates } from './services/updater.js' |
8 | 10 | import { closeAllSessions } from './services/sessions.js' |
@@ -41,15 +43,44 @@ protocol.registerSchemesAsPrivileged([ |
41 | 43 | { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, |
42 | 44 | ]) |
43 | 45 |
|
| 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 | + |
44 | 68 | /** Serves the exported renderer, and nothing outside it. */ |
45 | 69 | function serveBundle(): void { |
46 | | - const root = normalize(join(here, '..', '..', 'out')) |
| 70 | + const root = bundleRoot() |
47 | 71 | protocol.handle(APP_SCHEME, async (request) => { |
48 | 72 | const target = resolveBundlePath(root, new URL(request.url).pathname) |
49 | 73 | if (!target) return new Response('Forbidden', { status: 403 }) |
50 | 74 | try { |
51 | 75 | 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 | + }) |
53 | 84 | } catch { |
54 | 85 | return new Response('Not found', { status: 404 }) |
55 | 86 | } |
@@ -94,22 +125,7 @@ function createWindow(): BrowserWindow { |
94 | 125 | callback({ |
95 | 126 | responseHeaders: { |
96 | 127 | ...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()], |
113 | 129 | }, |
114 | 130 | }) |
115 | 131 | }) |
|
0 commit comments