diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-09 15:48:53 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-09 15:48:53 +0200 |
| commit | 56776934c2ddfbad4884b252da6f5fb25864b8db (patch) | |
| tree | b34aa2a9b71a3129c937d9d0cecaa39505afbd6b /packages/meshbay-hub/tests/test_desktop_shell.py | |
| parent | 78b309d18efdd227c0efa42464988eaaf1483c16 (diff) | |
| download | meshbay-56776934c2ddfbad4884b252da6f5fb25864b8db.tar.gz | |
fix: the PDF preview needs object-src and frame-src, in both policies
A PDF preview showed the "this browser will not display the PDF inline"
fallback everywhere — in the desktop client since its first launch, and in
the browser since the hub started sending a CSP on 2026-09-01. It read as a
missing native feature because before that commit the hub sent no policy at
all, so Chrome had once worked and the application never had.
Two directives govern one feature. `files-app.js` decrypts the file in the
page and hands it to `<object type="application/pdf">` from a Blob; Chromium
loads that as plugin data (`object-src`, absent and therefore falling back to
`default-src 'none'`) and then renders it in an internal frame (`frame-src`).
Opening either alone changes nothing visible — the second refusal produces the
same fallback. `'self'` covers neither: a same-origin `blob:` URL is not
matched by it in either directive, measured in Chrome 152 against the deployed
page and in Electron 44 against the client's own policy.
`plugins` stays at its default `false`: the built-in viewer is not behind that
flag on Electron 44, verified by rendering one.
Widening `object-src` from `'none'` to `blob:` admits only what page script
minted itself, at a type this code sets — PDFium parsing bytes that came from
a node, which is what any browser does with the same file once downloaded.
Tests: each policy is pinned to carry `blob:` in both directives (each fails
if either token is removed), and the two policies are now held identical
directive by directive apart from the two deliberate differences — the comment
claiming they were the same had already drifted and nothing checked it. The
CSP source parser in test_desktop_shell.py read `//` comment lines as
directives, which is the "parse directives, not text" mistake this file
already records; it skips them now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XauykfBvRrpy6RYbF6F7Wu
Diffstat (limited to 'packages/meshbay-hub/tests/test_desktop_shell.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 79 |
1 files changed, 77 insertions, 2 deletions
diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index c9d8683..039ea2f 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -184,9 +184,15 @@ def _policy() -> str: body = match.group(1) if rec: body = body.replace("${RECAPTCHA_SRC}", rec.group(1)) + # Skip the `//` comments inside the array. Reading one as a directive is + # the mistake this file already records against the packaged unit test, + # which matched the comment explaining why `User=` was absent: parse + # directives, not text. A comment line here yielded a phantom `//` + # directive the moment one was written. + lines = [line.strip() for line in body.splitlines()] return "; ".join( - line.strip().strip('`",').strip('`"') - for line in body.splitlines() if line.strip()) + line.strip('`",').strip('`"') + for line in lines if line and not line.startswith("//")) def _directive(name: str) -> str: @@ -240,6 +246,75 @@ def test_recaptcha_is_the_only_third_party_and_stays_scoped_to_it(): assert tok in hosts, f"unexpected external origin in CSP: {tok}" +def test_the_pdf_preview_has_both_permissions_it_needs(): + """ + The application shows a PDF the same way the browser does, and needs the + same two permissions to do it — this is where it was missing them. + + `files-app.js` decrypts the file in the renderer and hands it to + `<object type="application/pdf">` from a Blob. Chromium's viewer loads that + as plugin data (`object-src`) and renders it in an internal frame + (`frame-src`); with either refused, the "will not display the PDF inline" + fallback is what the user gets. Measured on Electron 44 (Chromium 152): + with both opened the viewer renders, with `plugins` left at its default + `false` — the built-in viewer is not behind that flag, so do not turn it on + to fix a PDF. + + `'self'` would not do: a same-origin `blob:` URL is not matched by it in + either directive. + """ + for name in ("object-src", "frame-src"): + directive = _directive(name) + assert directive, f"no {name} directive in the main process CSP" + sources = directive.split()[1:] + assert "blob:" in sources, ( + f"{name} refuses the decrypted PDF; the preview shows its fallback " + f"message in the desktop client") + assert "*" not in sources + + +def test_the_two_policies_stay_in_step(): + """ + One interface, two policies: this one and the hub's + (`meshbay_hub.api.webapp.CSP`), sent for the very same files. A directive + added to one and not the other is a feature that works in the browser and + not in the application, or the reverse. The PDF preview is what made the + duplication visible: the client sent a policy from its first launch and the + hub sent none until 2026-09-01, so the same markup worked in Chrome and not + in the application, and the difference read as a missing native feature + rather than as a policy nobody had compared. + + Two differences are deliberate and named here. Everything else must match. + """ + from meshbay_hub.api.webapp import CSP as HUB_CSP + + def directives(policy: str) -> dict[str, set[str]]: + out = {} + for part in policy.split(";"): + tokens = part.strip().split() + if tokens: + out[tokens[0]] = set(tokens[1:]) + return out + + app, hub = directives(_policy()), directives(HUB_CSP) + assert set(app) == set(hub), ( + f"a directive exists in one policy only: {set(app) ^ set(hub)}") + + # Nothing frames an `app://` page, so the client refuses every ancestor; + # the hub allows itself, for the streamed download's hidden iframe. + assert app["frame-ancestors"] == {"'none'"} + assert hub["frame-ancestors"] == {"'self'"} + # That same iframe is why the hub's frame-src carries `'self'`. The client + # has no service worker to ask (Chromium refuses one on a custom scheme), + # so it saves through the native dialog and needs no same-origin frame. + assert hub["frame-src"] - app["frame-src"] == {"'self'"} + + for name in sorted(set(app) - {"frame-ancestors", "frame-src"}): + assert app[name] == hub[name], ( + f"{name} has drifted: app {sorted(app[name])} vs " + f"hub {sorted(hub[name])}") + + # ── The bridge ────────────────────────────────────────────────────────────── def test_the_bridge_is_the_only_way_in(): |