fix(workspace): a portal share preview is fetched from the page's own origin (#2733) - #2738
Merged
Merged
Conversation
…tal base URL Three live docs claim the preview fetch inherits `download_url`'s origin and that the missing `Range` in `main.py`'s CORS `allow_headers` is what bounds it. Both halves stop being true once `sharePreviewPath` slices the path from `/api/files/` (#2733), so state today's rule where the next reader will re-derive the old rationale: - `requirements/core-agent.md` AC-4: correct the premise; the whole-blob read stays because the 256 KB cap is client-side slicing, not because a ranged preview is impossible. - `architecture/workspace.md`: the Files-tab verbs block gains the rule and its reason — `connect-src` is a build artefact, the portal base URL is a runtime setting, and a portal base URL on another host is a SUPPORTED topology (ent#79), not a misconfiguration. - `feature-flows/workspace-rail.md`: the Preview bullet loses the falsified CORS clause; the Testing block names both guards. Present tense throughout — the issue number is a lookup key, not narration (architecture.md editorial rule 3). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…ew fetch Red before the fix. Two of these assertions changed rather than appeared, and both changes are deliberate: - `portalFiles.spec.js` "share preview URL" asserted the BUG — it pinned the cross-origin url the browser then refused under `connect-src`. Flipped to the same-origin path. This is the #1638 class (a defect shipping green because a test asserted it), so it is called out rather than quietly rewritten. - the `sameOriginPath` cross-origin test at the top of the file is NOT touched. Its subject is the general helper, whose contract is unchanged; only its title is renamed to say so, because sitting beside the new rule it read as a contradiction instead of as a second scope. New `#2733` describe: the cross-origin rewrite asserted as a property (`new URL(result, base).origin === base`) and not only as a string, the default install byte-identical, a path-prefixed portal base URL, a port-only origin difference, a protocol-relative base, the fragment drop, the sig token across the rewrite, the input left unmutated, and the two negative pins — another route falls through absolute, and traversal cannot steer the rewrite. `test_1400_csp_blob_preview.py` gains the contract half of the same bug class, and asserts no JavaScript syntax: `connect-src` is a frozen SET in both CSP sources (a literal origin has no wildcard and no placeholder, so a shape check would pass the one move #2733 rejects), the share-route literal must match the f-string `client_portal/service.py` builds `download_url` from, and the loader carries the `@csp-coupled:` marker. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
… origin The Files rail's preview handed `fetch()` the absolute `download_url` `portal_documents` builds off `get_portal_base_url()`. Where that resolves to a different host — a dedicated public agent hostname beside the app hostname, which ent#79 exists to make possible — the browser refused the request against `connect-src 'self' blob: ws: wss: <two build-time hosts>` and the preview never loaded. Download was unaffected: an anchor navigation is not governed by `connect-src`. Widening `connect-src` is the wrong lever, for two independent reasons. The origin is a per-deployment runtime SETTING and both CSP sources are static build artefacts (#1224's ask-trinity host worked precisely because it is a build-time constant). And even with the header widened the fetch would still face CORS: `config.py::CORS_ORIGINS` allowlists the agent hostname, not necessarily the app hostname the portal page runs on — a CSP-only fix would have shipped, passed review, and still not previewed. So stop asking cross-origin. `sharePreviewPath` slices the path from `/api/files/` onward and drops the origin. The route carries no authority — it is public and the 192-bit `?sig=` token is the sole credential, compared with `compare_digest` against the stored row rather than signed over the url — and the portal page's own origin is guaranteed to serve `/api/` (prod nginx proxy, the Vite dev proxy, `api.js`'s empty `baseURL`; the Workspace could not load otherwise). `download_url` itself is never mutated, so the anchor-click Download and the shareable link are unchanged by construction, and a client upload takes the other branch of `loadBlob` entirely. The slice is taken from the route, not from the path root, so a portal base URL carrying a path prefix resolves to the same `/api/files/{id}` here rather than a path this origin never serves — and `lastIndexOf`, because the server appends the route last. Every output is therefore either unchanged or a path under `/api/files/`: the rewrite cannot be steered at another route, and a url with no `/api/files/` in it falls through untouched rather than having a local path invented for it. `sameOriginPath`'s body is untouched — the general helper's contract is honest — but its docblock loses "that is the deployment's problem" for the regression note AC 7 asks for. `PortalFilePreview.vue` and `portal_documents` get the same correction as comments only, the latter so the next consumer of `download_url` meets the rule at the producer. PDF preview stays out of scope: `previewKind` returns 'none' for `application/pdf` and always has, so a PDF row never reaches this code path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
Wrapping only — no claim changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
The rail source guard asserted only the positive — that a `sharePreviewPath` call reaches `fetch`. That still passes with a second, unrewritten `fetch(row.item.download_url)` added beside it, which is the shape #2733 comes back in: one rewritten caller and one that never was. Neither the behaviour tests (they exercise the pure function, not its call sites) nor the route-literal contract guard (it compares two constants) can see that. Proved to bite by injecting exactly that line into `PortalRailFiles.vue` — the guard fails; reverted, 64 pass. Green on the real source, which holds exactly one `fetch(`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
…t bug report The entry the plan reserved for the review stage (.plan/issue-2733.md §11.1). Two classes met in #2733 and neither is covered by the existing 2026-07-01 CSP entry, which is about a missing SOURCE (`blob:`) rather than a source that can never be added: a docblock calling a supported deployment topology "the deployment's problem", and the #1638 shape — a test asserting the defect as the requirement — arriving through a frontend door. Records what the guard has to be, since this review proved the weaker forms: freeze the connect-src SET rather than shape-check it (a literal host has no wildcard and no placeholder, and keeps the in-sync test green), pin the backend route literal across the language boundary, and note that no unit test over a pure URL helper can see a CSP refusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB
vybe
marked this pull request as ready for review
September 12, 2026 20:00
# Conflicts: # docs/memory/learnings.md
Contributor
|
merge-train: pushed |
11 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Workspace Files rail handed
fetch()the absolutedownload_urlthatportal_documentsbuilds offget_portal_base_url(). Where that resolves to a different host — a dedicated public agent hostname beside the app hostname, which ent#79 exists to make possible — the browser refused the request againstconnect-srcand the preview never loaded (Download was unaffected: an anchor navigation is not governed byconnect-src). The fix is frontend-only:sharePreviewPathslices path + query from/api/files/onward and asks the portal page's own origin for the bytes.download_urlitself is never mutated, andsameOriginPath— the general helper — keeps its body and its honest contract; only its docblock changes.Fixes #2733
What changed
src/frontend/src/components/portal/portalFiles.js— the behaviour change.sharePreviewPathgains aSHARED_FILE_ROUTE = '/api/files/'constant and returns${pathname.slice(at)}${search}when the route is present, falling through to the previoussameOriginPathbehaviour when it is not.sameOriginPath's body is untouched; its docblock loses "that is the deployment's problem" for the regression note AC 7 asks for.src/frontend/tests/unit/portalFiles.spec.js— +13 tests (51 → 64): the new#2733describe block, plus one deliberate assertion flip (see below).tests/unit/test_1400_csp_blob_preview.py— +4 tests (3 → 7): the CSP↔loader contract guards.src/frontend/src/components/portal/PortalFilePreview.vueandsrc/backend/client_portal/service.py— comment-only. Both carried the now-falseRange/CORS rationale; the backend docstring gains the rule at the producer, so the next consumer ofdownload_urlmeets it there.docs/memory/architecture/workspace.md(+2 sentences),docs/memory/feature-flows/workspace-rail.md(the Preview bullet's falsifiedRange/CORS clause + the Testing bullet),docs/memory/requirements/core-agent.md(AC-4 carried the identical falsified sentence),docs/memory/learnings.md(the/reviewentry).No new endpoint, no schema change, no new surface, no new copy, no token or primitive change.
raw-color-baseline.jsonandloading-gate-baseline.jsonare unmodified.Acceptance criteria
connect-srcviolation. Image and text: ticked (text proven in a real browser, below; images take the identicalloadBlobpath and the same rewritten URL).previewKind()returns'none'forapplication/pdf,portalFiles.js:75-86, pinned byportalFiles.spec.js:83); a PDF row shows the designed Download card, which is correct, not this fix failing. Out of scope by the issue owner's decision — listed under follow-ups.download_url's origin, with?sig=andpreview=1preserved.download_urland the anchor-clickContent-Disposition: attachmentDownload path are unchanged. Pinned by a test that asserts the input string is not mutated, and confirmed in the browser run: the branch still receives an absolute cross-origindownload_urlfrom the backend.portal.fetchUploadBlob(...)branch ofloadBlobentirely. Pinned as a source guard.download_url) are byte-identical. Pinned as its own test.portalFiles.spec.js(behaviour) andtest_1400_csp_blob_preview.py(the CSP↔loader contract).sameOriginPath's own docblock, and a guard scoped to that docblock (not a whole-file grep) pins that it stays.One test assertion was deliberately flipped — the old expected value WAS the bug
portalFiles.spec.jsasserted:That is the #1638 class arriving through a frontend door: a defect shipping green because a test states it as the requirement. It pinned the exact cross-origin URL the browser then refused. It now expects
/api/files/f1?sig=t&preview=1, with a comment at the assertion saying which side the old one was on.A second, neighbouring test was retained, only renamed:
sameOriginPath, the general helper, still leaves a genuinely cross-origin URL alone. Two scopes, not a contradiction — the share rule that does rewrite lives insharePreviewPath, because only that caller knows which route it is holding.Why not widen CSP
connect-srcTwo independent reasons, either one sufficient:
portal_base_urlis a database setting resolved at request time.security-headers.confand the mirrored dev CSP invite.config.jsare fixed text. bug: Help chat (Ask Trinity) blocked by CSP — connect-src omits ask-trinity Cloud Function #1224's ask-trinity host could be added precisely because a Cloud Function URL is a build-time constant; this one has no such value to bake in.config.py::CORS_ORIGINSallowlists the agent hostname, not necessarily the app hostname the portal page runs on — andmain.py'sallow_headersis the sibling gapPortalFilePreview.vuealready documented. A CSP-only fix would have shipped, passed review, and still not previewed.So the fix removes the dependency instead of feeding it: stop asking cross-origin.
Verification
/verify-local --skip-agentonce37de2b(verify projecttrinity-verify-93a5c833, run 20260912-134414) → status=pass:Affected neighbourhood, re-run on the pushed tip:
Real-browser reproduction. Playwright, Chromium headless, against the live backend with
portal_base_url = http://127.0.0.1:<port>while the page is browsed athttp://localhost:<port>— a genuinely different origin by the same rule a production split-hostname deployment hits. Base Vite on :5176 at7a40408b4, branch Vite on :5175. No unit test can see a CSP refusal, which is why this was run.BEFORE — base
7a40408b4, after clicking a shared text file:No network request was made at all, and
[text head]is empty — the screenshot shows the preview modal open with an empty body. That is the reported bug.AFTER — this branch:
Console clean, 5,846 characters rendered; the screenshot shows the document body in the modal. Note the
download_urlline: the backend still emits the absolute cross-origin URL on the branch (AC 3 preserved) — only the fetch moved tohttp://localhost:5175, the page's own origin. Thesigvalues above are truncated to their first 8 characters; everything else is verbatim.Guards
Three, each proved to bite by temporarily breaking it:
connect-srcis a frozen SET in both CSP sources (test_connect_src_is_a_frozen_set, parametrized nginx + vite). Set equality, not a shape check — the lever a developer actually pulls to "fix" this is a literal host, which carries no wildcard and no placeholder, and adding it to both files keeps the pre-existing in-sync test green. Proof: addinghttps://files.customer.comto both sources turns both parametrizations red whiletest_csp_sources_in_sync_for_blob_directivesstays green — exactly the gap this guard was written for.test_share_route_literal_matches_the_backend):portalFiles.js'sSHARED_FILE_ROUTEmust equal thef"/api/files/{...}"literalclient_portal/service.pybuildsdownload_urlfrom. Proof: repointing the constant to/api/v1/files/fails the test instead of silently re-breaking preview.sharePreviewPath(...)call reachesfetch— which still passes when a second, unrewrittenfetch(row.item.download_url)is added beside it, and that is the shape this bug returns in. Proof: injecting exactly that line intoPortalRailFiles.vueturns the new assertion red.Plus
@csp-coupled:as a stable marker comment on the loader, so the reason sits where the next editor reads it.Follow-ups (not filed — for the maintainer)
previewKind()returns'none'forapplication/pdf; a PDF row shows the Download card. The operator-sidecomponents/file-manager/FilePreview.vuehas an<embed>pattern to reuse, and CSP already allowsblob:onobject-srcandframe-srcfrom bug: Files tab preview broken for text/media files — CSP connect-src missing blob: blocks fetch(blob:) (regression of #784, introduced by #641) #1400, so no header change would be needed — but it is a new capability with its own size-cap, failure-state and mobile questions. The issue owner chose to keep it out of this P1 bug fix./api/files/{id}per-IP rate-limit bucket collapses behind the app front door.nginx.conf:44overwritesX-Real-IPwith$remote_addr(and so do its two sibling proxy blocks, :64 and :97), so preview traffic now arriving through the app hostname shares one bucket. Levers:set_real_ip_from/real_ip_header, or a separate bucket forpreview=1. Pre-existing on every default install (wheredownload_urlis already relative); this fix widens the set of deployments that reach it.portal_base_urlhelp text should say the origin must serve/api/. The setting's UI copy does not state the constraint the resolver depends on.docs/memory/feature-flows.md:35— that dated index row still carries the old CORS/Rangerationale; the flow doc itself is corrected here. (Per the issue owner's decision, this PR adds no new "Recent Updates" row — the flow doc records the change.)sig" house figure.secrets.token_urlsafe(32)is 256 bits, not 192; the 192 figure is repeated at 19 sites across 12 files, including the docblock added here. Worth one sweep rather than a drive-by correction inside a bug fix..claude/agents/test-runner.mdcatalog row —portalFiles.spec.js+13,test_1400_csp_blob_preview.py+4. The.claudesubmodule was read-only in this run, so the row could not be written.No labels were applied to this PR. That costs nothing on the e2e side: since test(ci): frontend-e2e only runs on ui-labeled PRs — specs rot silently and the suite sits red on dev #1526
frontend-e2eruns automatically on any PR touchingsrc/frontend/**, and theuilabel is only a manual opt-in for non-frontend PRs — thee2echeck is already queued here.R3 — a noted, accepted residual
On a cross-origin deployment the
sigtoken now also appears in the app front door's access log, because the preview request goes to the app hostname instead of the portal base URL's host. This is already true on every default install (wheredownload_urlis relative and the preview has always been same-origin), so the change is which of two Trinity-operated logs holds it, not whether it is logged. Judged by/cso --diffand accepted.🤖 Generated with Claude Code
https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB