aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 10:30:59 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 10:30:59 +0200
commit68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (patch)
tree2a9ae0352ec09aca413448c9b2e5e9a638b36471 /packages/meshbay-client/src
parent30e855f55f1d920b25da0bdd8e538c249d3c0c26 (diff)
downloadmeshbay-68bfe56a19aeb4c16f8e185fc85d8eee61aef78f.tar.gz
fix(client): the desktop client runs, and running it corrected three things
Electron 42 / Chromium 148, launched under xvfb. The packaged interface mounts over `app://` with a secure context, `crypto.subtle` present, Argon2 WASM loaded, and no console errors. Three statements in the design were wrong, and only launching it found them. **A CSP in a `<meta>` tag silently drops `frame-ancestors`.** Chromium says so in the console. A policy carrying a directive that does nothing is worse than one without it, so the policy is sent as a header by the protocol handler — which is also the only thing serving the interface, so one source instead of two. **`secure: true` is not what makes the service worker register.** Chromium refuses a worker on a custom scheme whatever its privileges: "The URL protocol of the current origin ('app://meshbay') is not supported". The application has no service worker and needs none — it saves through a native dialog, which is the better of the two paths. `sw.js` stays in the package because the same files serve the browser, where it is one of only three ways to write a large file. What `secure: true` is actually for was measured at the same time: without it **the whole of `crypto.subtle` is undefined**. The first probe loaded a `data:` URL and every algorithm failed with TypeError, AES-GCM included — which is why the probe was rewritten before believing its answer. X25519 and Ed25519 are both present on Chromium 148, settling the version floor left open as O6. **The renderer cannot call the hub.** Its origin is `app://meshbay` and CORS refuses it. The hub has *no CORS middleware at all* — its API is reachable from no web origin whatever — and that is worth keeping. Widening it for `app://meshbay` would be worse than it looks: that origin is not a credential, since any Electron application can claim the same scheme and host name. So every hub call leaves from the main process, exactly as saving a file does, and it refuses any origin that is not the hub the user signed in to. `platform.apiFetch()` is `fetch` in a browser and the bridge in the application, so no caller has to know which it got. `transport.js` reaches it through a global because it is a classic script, not a module — the alternative was a second fetch path, which is how two callers of one hub start disagreeing about how to reach it. Verified from inside Electron: the main process gets 200 from /v1/hub/version, the renderer is refused by CORS, and **a script served by the hub is refused by the policy** — T3's mitigation demonstrated rather than asserted. Build note, written into the README because it will bite the next person: **Ubuntu 24.04's nodejs 18 cannot install Electron at all** — the download script `require()`s an ESM module, which Node gained in 22. Node 24 LTS, checksum-verified against nodejs.org, is what this was built with. package-lock.json is committed; builds use `npm ci`, not `npm install`. 799 tests pass, e2e.py still passes end to end. The session harness needed a platform stub: it lifts `hubFetch` out of app.js as text and runs it, so the adapter is now part of the environment it models. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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),