aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-client/src')
-rw-r--r--packages/meshbay-client/src/main.js86
-rw-r--r--packages/meshbay-client/src/preload.js4
2 files changed, 84 insertions, 6 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 3fc52ff..867ce27 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -30,16 +30,53 @@ const { pathToFileURL } = require('node:url');
const UI_DIR = path.join(__dirname, '..', 'ui');
const SCHEME = 'app';
+// The policy, sent as a header on every response.
+//
+// Not a <meta> tag: `frame-ancestors` is ignored there — Chromium says so in
+// the console — and a policy with a directive that silently does nothing is
+// worse than one without it. A header is authoritative for every directive,
+// and this handler is the only thing that serves the interface, so there is one
+// source rather than two that can drift.
+//
+// `'wasm-unsafe-eval'` is required and must stay: the bundle key is Argon2id in
+// WebAssembly, and a policy without it does not degrade anything — it locks
+// every user out of their keys.
+//
+// The hub is reachable under connect-src, for its API and its signaling socket.
+// It is deliberately absent from script-src: nothing it returns is executed,
+// which is the whole reason this application exists (T3).
+const CSP = [
+ "default-src 'none'",
+ "script-src 'self' 'wasm-unsafe-eval'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: blob:",
+ "media-src 'self' blob:",
+ "font-src 'self'",
+ "connect-src 'self' https: wss:",
+ "worker-src 'self'",
+ "frame-ancestors 'none'",
+ "base-uri 'none'",
+ "form-action 'none'",
+].join('; ');
+
// ── The scheme the interface is served from ─────────────────────────────────
//
// Not file://. Service workers, ES modules and IndexedDB all misbehave there,
// and the streamed-download path needs a *controlled* page — a worker that is
// merely active is not enough, which this codebase has already learned once.
//
-// `secure` is what makes it a secure context, without which the worker refuses
-// to register and downloads break with no error at all. `standard` gives it a
-// real origin, so IndexedDB survives an update instead of being keyed to
-// something that moves.
+// `secure` is what makes it a secure context, and without it the whole of
+// `crypto.subtle` is undefined — measured on Electron 42 / Chromium 148, where
+// a page on a non-secure scheme failed every algorithm including AES-GCM.
+// `standard` gives it a real origin, so IndexedDB survives an update instead of
+// being keyed to something that moves.
+//
+// What it does NOT buy: service workers. Chromium refuses to register one on a
+// custom scheme whatever its privileges ("The URL protocol of the current
+// origin is not supported"). So `sw.js` never runs here, and the streamed
+// download it exists for is replaced by the native save dialog below — which is
+// the better path anyway. It stays in the package because the same files serve
+// the browser, where it is one of only three ways to write a large file.
protocol.registerSchemesAsPrivileged([{
scheme: SCHEME,
privileges: {
@@ -70,7 +107,11 @@ function registerUiProtocol() {
try {
const body = await fsp.readFile(target);
return new Response(body, {
- headers: { 'Content-Type': contentType(target) },
+ headers: {
+ 'Content-Type': contentType(target),
+ 'Content-Security-Policy': CSP,
+ 'X-Content-Type-Options': 'nosniff',
+ },
});
} catch {
return new Response('Not found', { status: 404 });
@@ -252,6 +293,39 @@ function registerBridge() {
return url;
});
+ // Every call to the hub leaves from here, not from the renderer.
+ //
+ // Not a preference. The page's origin is `app://meshbay`, and a browser fetch
+ // from it is refused by CORS — the hub has no CORS middleware at all, which is
+ // a posture worth keeping: its API is reachable from no web origin whatever.
+ // Widening it for `app://meshbay` would be worse than it looks, because that
+ // origin is not a credential — any Electron application on any machine can
+ // claim the same scheme and host.
+ //
+ // So the renderer asks and this process goes, exactly as it does for saving a
+ // file. Node's fetch has no origin and no CORS, the hub stays closed to the
+ // web, and there is one place where network egress happens.
+ ipcMain.handle('hub:fetch', async (_e, url, init) => {
+ const target = new URL(String(url));
+ const base = config.hubBase ? new URL(config.hubBase) : null;
+ // The renderer may only reach the hub it is signed in to. A path it
+ // controls must not become a request to somewhere else.
+ if (!base || target.origin !== base.origin) {
+ throw new Error('Refused: not this hub');
+ }
+ const response = await fetch(target, {
+ method: (init && init.method) || 'GET',
+ headers: (init && init.headers) || {},
+ body: (init && init.body) || undefined,
+ });
+ return {
+ status: response.status,
+ ok: response.ok,
+ headers: Object.fromEntries(response.headers),
+ body: await response.text(),
+ };
+ });
+
ipcMain.handle('secrets:backend', () => secretsBackend());
ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null);
ipcMain.handle('secrets:set', (_e, name, value) => {
@@ -328,4 +402,4 @@ if (!app.requestSingleInstanceLock()) {
});
}
-module.exports = { contentType, secretsBackend };
+module.exports = { contentType, secretsBackend, CSP };
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index 9c273e2..e3e240f 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -42,6 +42,10 @@ contextBridge.exposeInMainWorld('meshbay', {
nativeSave: true,
},
+ // Ask the main process to call the hub. The renderer has an `app://` origin,
+ // which CORS refuses and which is not a credential anyway.
+ fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init),
+
secrets: {
get: (name) => ipcRenderer.invoke('secrets:get', name),
set: (name, value) => ipcRenderer.invoke('secrets:set', name, value),