Skip to content

fix(workspace): a portal share preview is fetched from the page's own origin (#2733) - #2738

Merged
vybe merged 7 commits into
devfrom
vybe/issue-2733
Sep 13, 2026
Merged

vybe merged 7 commits into
devfrom
vybe/issue-2733

Conversation

@trinity-ability

@trinity-ability trinity-ability commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

The Workspace Files rail handed fetch() the absolute download_url that 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 and the preview never loaded (Download was unaffected: an anchor navigation is not governed by connect-src). The fix is frontend-only: sharePreviewPath slices path + query from /api/files/ onward and asks the portal page's own origin for the bytes. download_url itself is never mutated, and sameOriginPath — 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. sharePreviewPath gains a SHARED_FILE_ROUTE = '/api/files/' constant and returns ${pathname.slice(at)}${search} when the route is present, falling through to the previous sameOriginPath behaviour 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 #2733 describe 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.vue and src/backend/client_portal/service.py — comment-only. Both carried the now-false Range/CORS rationale; the backend docstring gains the rule at the producer, so the next consumer of download_url meets it there.
  • Docs — docs/memory/architecture/workspace.md (+2 sentences), docs/memory/feature-flows/workspace-rail.md (the Preview bullet's falsified Range/CORS clause + the Testing bullet), docs/memory/requirements/core-agent.md (AC-4 carried the identical falsified sentence), docs/memory/learnings.md (the /review entry).

No new endpoint, no schema change, no new surface, no new copy, no token or primitive change. raw-color-baseline.json and loading-gate-baseline.json are unmodified.

Acceptance criteria

  • AC 1 — preview of an agent-shared file works when the resolved portal base URL is a different origin, no CSP connect-src violation. Image and text: ticked (text proven in a real browser, below; images take the identical loadBlob path and the same rewritten URL).
    • PDF: not applicable — the portal rail has never previewed PDFs (previewKind() returns 'none' for application/pdf, portalFiles.js:75-86, pinned by portalFiles.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.
  • AC 2 — the preview blob is fetched same-origin (path + query only) regardless of download_url's origin, with ?sig= and preview=1 preserved.
  • AC 3 — the absolute download_url and the anchor-click Content-Disposition: attachment Download 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-origin download_url from the backend.
  • AC 4 — client-upload rows are unaffected; they have no DB row and no URL, and take the portal.fetchUploadBlob(...) branch of loadBlob entirely. Pinned as a source guard.
  • AC 5 — deployments with no portal base URL (relative download_url) are byte-identical. Pinned as its own test.
  • AC 6 — pinned by portalFiles.spec.js (behaviour) and test_1400_csp_blob_preview.py (the CSP↔loader contract).
  • AC 7 — the regression note sits on 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.js asserted:

expect(sharePreviewPath('https://cdn.example.com/api/files/f1?sig=t', 'https://portal.example.com'))
  .toBe('https://cdn.example.com/api/files/f1?sig=t&preview=1')

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 in sharePreviewPath, because only that caller knows which route it is holding.

Why not widen CSP connect-src

Two independent reasons, either one sufficient:

  1. The origin is a per-deployment runtime setting; both CSP sources are static build artefacts. portal_base_url is a database setting resolved at request time. security-headers.conf and the mirrored dev CSP in vite.config.js are 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.
  2. 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 — and main.py's allow_headers is the sibling gap PortalFilePreview.vue already 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-agent on ce37de2b (verify project trinity-verify-93a5c833, run 20260912-134414) → status=pass:

  • unit: 15,341 passed / 31 skipped / 0 failed
  • build + import-smoke: OK · boot + health: OK
  • integration: 70 passed / 13 skipped / 2 registry-deselected / 0 failed

Affected neighbourhood, re-run on the pushed tip:

src/frontend: vitest portalFiles.spec.js portalRailFiles.spec.js rawColorRatchet.spec.js loadingGateRatchet.spec.js
  → 4 files, 97 passed   (portalFiles.spec.js 51 → 64)
tests: pytest unit/test_1400_csp_blob_preview.py unit/test_2582_download_flag.py unit/test_2582_portal_uploads.py \
       unit/test_ent548_portal_share_delete.py unit/test_ent79_portal_exposure.py unit/test_files_protected_paths.py
  → 225 passed           (test_1400_csp_blob_preview.py 3 → 7)
npm run test:unit → 125 files / 2,745 passed · npm run check:tokens → OK · both ratchets green, baselines unmodified

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 at http://localhost:<port> — a genuinely different origin by the same rule a production split-hostname deployment hits. Base Vite on :5176 at 7a40408b4, 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:

[backend download_url] http://127.0.0.1:5176/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1
--- CLICK PREVIEW ---
[console.error] Connecting to 'http://127.0.0.1:5176/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1&preview=1' violates the following Content Security Policy directive: "connect-src 'self' blob: ws: wss: https://us-central1-mcp-server-project-455215.cloudfunctions.net https://intake.abilityai.dev". The action has been blocked.
[console.error] Fetch API cannot load http://127.0.0.1:5176/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1&preview=1. Refused to connect because it violates the document's Content Security Policy.
[result] preview-text=1 preview-card=0
[text head]

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:

[backend download_url] http://127.0.0.1:5175/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1
--- CLICK PREVIEW ---
[request->network] http://localhost:5175/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1&preview=1
[response] 200 http://localhost:5175/api/files/952dcc1d-dc3a-4e68-be35-40ccc8d6b4ab?sig=Iq6JU6H8…&download=1&preview=1
[result] preview-text-el=1 preview-text-chars=5846 preview-card=0
[verdict] CSP-refusal-after-click=false preview-bytes-rendered=5846

Console clean, 5,846 characters rendered; the screenshot shows the document body in the modal. Note the download_url line: the backend still emits the absolute cross-origin URL on the branch (AC 3 preserved) — only the fetch moved to http://localhost:5175, the page's own origin. The sig values above are truncated to their first 8 characters; everything else is verbatim.

Guards

Three, each proved to bite by temporarily breaking it:

  • connect-src is 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: adding https://files.customer.com to both sources turns both parametrizations red while test_csp_sources_in_sync_for_blob_directives stays green — exactly the gap this guard was written for.
  • Route-literal contract across the language boundary (test_share_route_literal_matches_the_backend): portalFiles.js's SHARED_FILE_ROUTE must equal the f"/api/files/{...}" literal client_portal/service.py builds download_url from. Proof: repointing the constant to /api/v1/files/ fails the test instead of silently re-breaking preview.
  • The rail's negative assertion: the source guard previously asserted only that a sharePreviewPath(...) call reaches fetch — which still passes when a second, unrewritten fetch(row.item.download_url) is added beside it, and that is the shape this bug returns in. Proof: injecting exactly that line into PortalRailFiles.vue turns 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)

  1. Portal PDF preview. previewKind() returns 'none' for application/pdf; a PDF row shows the Download card. The operator-side components/file-manager/FilePreview.vue has an <embed> pattern to reuse, and CSP already allows blob: on object-src and frame-src from 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.
  2. R4 — the /api/files/{id} per-IP rate-limit bucket collapses behind the app front door. nginx.conf:44 overwrites X-Real-IP with $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 for preview=1. Pre-existing on every default install (where download_url is already relative); this fix widens the set of deployments that reach it.
  3. S-F13 — the portal_base_url help text should say the origin must serve /api/. The setting's UI copy does not state the constraint the resolver depends on.
  4. docs/memory/feature-flows.md:35 — that dated index row still carries the old CORS/Range rationale; 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.)
  5. The "192-bit 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.
  6. The owed .claude/agents/test-runner.md catalog row — portalFiles.spec.js +13, test_1400_csp_blob_preview.py +4. The .claude submodule 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-e2e runs automatically on any PR touching src/frontend/**, and the ui label is only a manual opt-in for non-frontend PRs — the e2e check is already queued here.

R3 — a noted, accepted residual

On a cross-origin deployment the sig token 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 (where download_url is 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 --diff and accepted.

🤖 Generated with Claude Code

https://claude.ai/code/session_017YoUiMQgPj3BeFMpyYpfkB

trinity-ability and others added 6 commits September 12, 2026 13:14
…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
vybe marked this pull request as ready for review September 12, 2026 20:00
# Conflicts:
#	docs/memory/learnings.md
@vybe

vybe commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

merge-train: pushed 088fdf96c to this branch — a merge of origin/dev resolving the docs/memory/learnings.md append collision with #2740 (kept both entries; nothing else changed). Mechanical, per the train's routine-conflict class; no code touched.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: batch validated on train/20260913-0705 (#2745) — lane B, /validate-pr + /review clean; 11 behavioural vitest cases on the rewrite; chosen over #2737 for the narrower route-scoped rewrite and the corrected docs.

@vybe
vybe merged commit f2d1d27 into dev Sep 13, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants