Enforce header-based CSP and block window open/navigation

This commit is contained in:
Avi 2026-08-21 13:53:45 -05:00
commit 4d4dfde1de
2 changed files with 70 additions and 5 deletions

View file

@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol } from 'electron';
import { app, BrowserWindow, clipboard, dialog, ipcMain, protocol, shell } from 'electron';
import { spawn, type ChildProcess } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { readFileSync } from 'node:fs';
@ -26,6 +26,42 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } },
]);
/**
* Content-Security-Policy applied to every page this app loads.
*
* The production policy forbids inline scripts entirely (the Vite bundle is
* external), so an injected `<script>` in rendered content cannot execute. The
* dev-server policy keeps `'unsafe-inline'` because Vite's React-refresh
* preamble is an inline script, but still pins network access to localhost
* (HMR websocket included).
*/
const CSP_PROD =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self'; img-src 'self' data: https:; object-src 'none'; " +
"base-uri 'none'; form-action 'none'";
const CSP_DEV =
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' ws://localhost:* http://localhost:*; img-src 'self' data: https:; " +
"object-src 'none'; base-uri 'none'; form-action 'none'";
/** The CSP for a URL this window may load, or `null` for anywhere else. */
function cspForUrl(url: string): string | null {
if (url.startsWith('app://')) {
return CSP_PROD;
}
const devServer = process.env.NOSTR_GUI_DEV_URL;
if (devServer) {
try {
if (new URL(url).origin === new URL(devServer).origin) {
return CSP_DEV;
}
} catch {
// Fall through: not a URL we recognise.
}
}
return null;
}
interface PendingRequest {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
@ -390,6 +426,39 @@ function createWindow(): void {
} else {
void window.loadURL('app://nfm/index.html');
}
// Stamp every top-level document with the matching CSP.
window.webContents.session.webRequest.onHeadersReceived((details, callback) => {
if (details.resourceType !== 'mainFrame') {
callback({});
return;
}
const csp = cspForUrl(details.url);
if (!csp) {
callback({});
return;
}
callback({
responseHeaders: { ...details.responseHeaders, 'Content-Security-Policy': [csp] },
});
});
// The app window never navigates away from its own origin; external links
// open in the system browser instead. Deny everything unrecognised outright.
window.webContents.on('will-navigate', (event, url) => {
if (cspForUrl(url) === null) {
event.preventDefault();
if (/^https?:/i.test(url)) {
void shell.openExternal(url).catch(() => {});
}
}
});
window.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) {
void shell.openExternal(url).catch(() => {});
}
return { action: 'deny' };
});
}
app.whenReady().then(() => {