Serve document bytes, not the app shell, to <object>/<embed> loads - #5816
Serve document bytes, not the app shell, to <object>/<embed> loads#5816lukemelia wants to merge 1 commit into
Conversation
lukemelia
left a comment
There was a problem hiding this comment.
[Claude Code 🤖] This review focused on verifying the fix's transport-layer premise end-to-end — does the pinned Accept header actually reach the wire on an <object>/<embed> load? — because the whole change hangs on the service worker seeing those requests.
Bottom line: blocking. Service workers never receive fetch events for <object>/<embed> loads — the ServiceWorker specification makes them fall back to the network without dispatching an event, and I reproduced exactly that in Chrome 151 — so acceptOverrideFor never fires, the pinned Accept never leaves the browser, and the recursive app boot is still reachable. Full evidence (spec text, live-browser repro, captured request headers) is in the inline thread on acceptOverrideFor in packages/host/public/auth-service-worker.js.
What lands right. The diagnosis of the recursion is verified correct on both halves. The captured <embed> request really does go out frame-style with accept: text/html,application/xhtml+xml,... and sec-fetch-mode: navigate. And on the realm side, serveIndex in packages/realm-server/handlers/serve-index.ts serves the app shell for any text/html-accepting request without a vnd. type regardless of file extension, while with Accept: */* an extension-carrying path takes the hasExtension branch to next(), reaching the realm's fallbackHandle → serveLocalFile, which returns the document bytes. If the pinned header reached the wire, the realm would behave exactly as this description says. The interception point is the only wrong link in the chain.
Recommendations
- Refix at the realm's content negotiation:
serveIndexshould decline to serve the app shell when the request carriesSec-Fetch-Dest: embedorobject— these loads self-identify on the wire, address-bar navigations carrySec-Fetch-Dest: documentand keep working, and no client change is needed. Detail in theacceptOverrideForthread. - Pin whichever fix lands with a wire-level realm-server integration test (frame-style request for a file URL asserting bytes, not shell) — detail in the thread on the test scaffold in
packages/host/tests/unit/auth-service-worker-test.ts. - Follow-up worth filing: private-realm documents cannot authenticate through native embeds at all — the same bypass blocks
Authorizationinjection, so the native-viewer approach needs an auth story (authed fetch +blob:, delegated/signed URLs, or cookie-scoped sessions).
Adjacent, out of scope. The comment in packages/base/file-formats/pdf-viewer.gts stating the auth service worker injects the realm token on the native <object> request describes a path that does not exist (same bypass). Pre-existing and not this change's doing, but worth correcting when the real fix lands so the next reader doesn't build on it again.
442b914 to
10e90a9
Compare
10e90a9 to
4b46c8d
Compare
An <object>/<embed> load is issued as a frame-style navigation whose Accept header includes text/html, and the realm serves the host app for any HTML-accepting request — so an embedded PDF viewer received the app shell and booted the app recursively inside the preview. No client-side layer can correct this: the ServiceWorker spec makes <embed>/<object> requests fall back to the network without dispatching a fetch event. Browsers do stamp Sec-Fetch-Dest on these loads (on trustworthy origins), so serveIndex/serveHostApp now decline to serve the shell when it is embed or object and fall through to the realm, which serves the file's own bytes. Address-bar navigations (Sec-Fetch-Dest: document, or absent) keep opening the app, and cacheable shell responses vary on Sec-Fetch-Dest so a cached shell can never satisfy an embed load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4b46c8d to
85b658f
Compare
What this does
Opening a PDF FileDef in the embedded or isolated view rendered the entire Boxel app recursively nested inside the preview. The chain: the PDF viewer mounts a native
<object data="…/file.pdf" type="application/pdf">; the browser issues<object>/<embed>loads as frame-style navigations whoseAcceptheader includestext/html; and the realm serves the host app for any request that accepts HTML (the same negotiation that lets a pasted file URL open in operator mode) — so the<object>receives the app shell, boots it, deep-links to the PDF card, mounts another<object>, and recurses.The negotiation is corrected on the server, because no client-side layer can do it: service workers are spec-required to pass
<object>/<embed>loads straight to the network without dispatching a fetch event (ServiceWorker spec §6.7; confirmed empirically in Chrome — a controlling SW sees<img>loads but never object/embed loads). These loads do self-identify on the wire, though: browsers stampSec-Fetch-Dest: embed/objecton them (on trustworthy origins — HTTPS and localhost), while an address-bar navigation carriesSec-Fetch-Dest: document.serveIndex/serveHostAppin the realm server now decline to serve the app shell when the request'sSec-Fetch-Destisembedorobject, falling through to the realm, which serves the file's own bytes with the file's own content type. Consequences of fixing at this layer:Sec-Fetch-Dest: document), and requests with noSec-Fetch-Destkeep their existing behavior.<object data="…">stays valid in any browsing context, where a component-fetchedblob:URL would be dead outside the context that created it.Sec-Fetch-DesttoVary, so a cached shell can never satisfy an embed load.Known limitation, unchanged by this PR:
<object>/<embed>loads can't carryAuthorization(the same service-worker bypass blocks token injection), so documents render in the native viewer only from publicly readable realms. Private-realm document auth through native embeds needs its own design — follow-up.Test plan
packages/realm-server/tests/server-endpoints/index-responses-test.ts(document embed negotiationmodule): a.pdfGET with a frame-styleAcceptplusSec-Fetch-Dest: embedorobjectreturnsapplication/pdfbytes, not the shell; the same URL withSec-Fetch-Dest: documentor noSec-Fetch-Deststill returns the app shell; the published-realm ETag response'sVaryincludesSec-Fetch-Dest. All touched modules green locally (the full file exhausts the local test-pg tmpfs in unrelated later modules, so they were run per-module).Fixes CS-12557.
🤖 Generated with Claude Code