diff options
Diffstat (limited to 'packages/meshbay-hub')
5 files changed, 96 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index abb3374..24ef43f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -237,7 +237,7 @@ async function refreshAccessToken() { if (_refreshing) return _refreshing; _refreshing = (async () => { try { - const r = await fetch(HUB + '/v1/users/token/refresh', { + const r = await platform.apiFetch(HUB + '/v1/users/token/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: _auth.refreshToken }), @@ -299,7 +299,7 @@ async function hubFetch(path, { method = 'GET', body, token, _retried } = {}) { if (bearer) headers['Authorization'] = `Bearer ${bearer}`; const opts = { method, headers }; if (body) opts.body = JSON.stringify(body); - const r = await fetch(HUB + path, opts); + const r = await platform.apiFetch(HUB + path, opts); if (r.status === 401 && bearer && !_retried) { // The one case worth a second attempt: the access token aged out while // nothing was talking to the hub. Renew once and replay. If the renewal diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 0663a42..fcc866e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -88,6 +88,40 @@ export const secrets = { }; /** + * Call the hub. + * + * In a browser this is `fetch`, unchanged — the page came from the hub, so the + * request is same-origin and nothing is in the way. + * + * In the application 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**, and + * that is worth keeping: its API is reachable from no web origin whatever. + * Widening it for `app://meshbay` would be worse than it appears, because that + * origin is not a credential — any Electron application can claim the same + * scheme and host name. + * + * So the main process makes the call. It returns a small object rather than a + * Response, and this shapes it back into something with `.ok`, `.status` and + * `.json()`, so callers do not have to know which one they got. + */ +export async function apiFetch(url, init) { + if (!bridge || !bridge.fetch) return fetch(url, init); + const raw = await bridge.fetch(String(url), init && { + method: init.method, + headers: init.headers, + body: init.body, + }); + return { + ok: raw.ok, + status: raw.status, + statusText: String(raw.status), + headers: new Headers(raw.headers || {}), + text: async () => raw.body, + json: async () => JSON.parse(raw.body), + }; +} + +/** * Save a decrypted file to disk. * * Returns null when there is no native path, so the caller keeps today's @@ -100,4 +134,13 @@ export async function nativeSave(suggestedName, size) { return bridge.saveFile(suggestedName, size); } -export default { isNative, hubBase, capabilities, secrets, nativeSave }; +export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch }; + +// Also a global, because `transport.js` is loaded as a classic script — it +// predates the module graph and exposes `MeshBayTransport` the same way. The +// alternative was a second fetch path there, which is how two callers of one +// hub end up disagreeing about how to reach it. +if (typeof window !== 'undefined') { + window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets, + nativeSave, apiFetch }; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 769114e..1a63e23 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -162,7 +162,13 @@ class MeshBayTransport { }; }); - const resp = await fetch(`${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { + // Signaling is a hub call like any other, so it goes the same way — in the + // application that means through the main process, because the renderer's + // app:// origin is refused by CORS. + const call = (window.MeshBayPlatform && window.MeshBayPlatform.apiFetch) + || fetch; + const resp = await call( + `${this._hubUrl}/v1/nodes/${nodeId}/webrtc/offer`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/packages/meshbay-hub/tests/harness/session_harness.mjs b/packages/meshbay-hub/tests/harness/session_harness.mjs index f0d0cb2..3be862e 100644 --- a/packages/meshbay-hub/tests/harness/session_harness.mjs +++ b/packages/meshbay-hub/tests/harness/session_harness.mjs @@ -110,6 +110,12 @@ const ctx = { localStorage, AUTH_KEY, HUB, TOKEN_RENEW_MARGIN_S, loadAuth, saveAuth, fetch: fakeFetch, atob: (s) => Buffer.from(s, 'base64').toString('binary'), console, + // Hub calls go through the platform adapter since the interface started + // shipping in a package: in a browser it is `fetch`, in the application it is + // the main process, because an `app://` origin is refused by CORS. The + // harness models the browser side, which is the one whose renewal logic this + // exercises. + platform: { apiFetch: (...args) => fakeFetch(...args) }, }; const fns = new Function(...Object.keys(ctx), sessionBlock + '\n' + hubFetchFn + diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 13d194e..c4f5b08 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -85,11 +85,18 @@ def test_no_permission_is_granted_to_the_page(): ]) def test_the_scheme_is_privileged(privilege): """ - Without `secure` the scheme is not a secure context, the service worker - silently refuses to register, and streamed downloads break with no error — - the same failure mode as an uncontrolled page, which this codebase has - already learned once. `standard` gives 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, not assumed: the first probe + loaded a `data:` URL and every algorithm failed with TypeError, AES-GCM + included. `standard` gives a real origin, so IndexedDB survives an update + instead of being keyed to something that moves. + + An earlier version of this docstring said `secure` was what let the service + worker register. That is wrong: Chromium refuses to register a worker on a + custom scheme whatever its privileges — "The URL protocol of the current + origin ('app://meshbay') is not supported". The application therefore has no + service worker and does not need one; it saves files through a native + dialog, which is better than the path the worker exists to provide. """ assert privilege in _main(), f"{privilege} missing from the scheme privileges" @@ -107,6 +114,18 @@ def test_the_protocol_handler_cannot_be_walked_out_of(): # ── Content Security Policy ───────────────────────────────────────────────── +def test_the_policy_is_sent_as_a_header(): + """ + A <meta> policy cannot carry `frame-ancestors`, and having it there means + one directive of the policy is decoration. The handler is also the only + thing that serves the interface, so this is one source rather than two. + """ + source = _main() + assert "'Content-Security-Policy': CSP" in source + assert "Content-Security-Policy" not in INDEX.read_text(encoding="utf-8") \ + .split("-->")[1], "the packaged page still carries a policy of its own" + + def test_the_policy_keeps_wasm_unsafe_eval(): """ The bundle key is Argon2id in WebAssembly. A policy that forbids it does not @@ -116,14 +135,20 @@ def test_the_policy_keeps_wasm_unsafe_eval(): def _policy() -> str: - """The meta tag's content, not the file — the comment above it names the - same directives and would satisfy a naive search.""" + """ + The policy the protocol handler sends, read out of the CSP constant. + + Not a <meta> tag: `frame-ancestors` is ignored there, and a directive that + silently does nothing is worse than one that is absent. Chromium said so in + the console the first time the application was launched. + """ import re - page = INDEX.read_text(encoding="utf-8") - match = re.search( - r'http-equiv="Content-Security-Policy"\s+content="([^"]*)"', page) - assert match, "no Content-Security-Policy meta tag" - return match.group(1) + source = _main() + match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S) + assert match, "no CSP constant in the main process" + return "; ".join( + line.strip().strip('",').strip('"') + for line in match.group(1).splitlines() if line.strip()) def _directive(name: str) -> str: |