fix(templates): frame the gallery preview from the sandbox origin - #26
Merged
Conversation
A BLANK PREVIEW ON CLOUD, AND A GREEN SUITE. The template detail page embedded `src="/templates/:slug/frame"` — a relative URL, and therefore same-origin. On a deployment with a sandbox host the app origin's `frame-src` names that host and nothing else, so the browser refused it before issuing a request: Framing 'https://agentartifact.ai/templates/project-plan/frame' violates the following Content Security Policy directive: "frame-src https://usercontent.agentartifact.ai". The request has been blocked. The endpoint was healthy the whole time — 200 and valid HTML for all sixteen HTML templates, never fetched. It read as a content bug and was not one. Re-admitting `'self'` to the app origin's `frame-src` is the fix that undoes the reason the second host exists: it would let framed HTML run scripts on the origin holding the owner's session cookie, which `preview-token.ts` spells out at length. So `lib/template-frame.ts` makes the same single choice `ViewerService.frameUrl()` and `ownerPreviewFrameUrl()` already make — `sandboxOrigin ?? baseUrl`. Self-hosted stays same-origin and unchanged; cloud points at the sandbox host. Nothing is conditional except the origin. THE SANDBOX HOST HAD TO BE TOLD. `isSandboxAllowedPath` trims that host to the handful of paths meant to answer there, and `/templates/:slug/frame` was not one of them — the fixed URL would have 404'd at our own guard before nginx was ever consulted. It is the third entry and the only one with no token in it, which it needs none of: a starter template is ours, is already served in full to any authenticated agent through `GET /v1/templates/:slug`, and is linked from a page with no account behind it. The pattern is imported from where the URL is built, so the host that answers and the page that embeds cannot describe different paths. THE SUITE COULD NOT HAVE CAUGHT THIS. Every existing test runs self-hosted, where `frame-src` is `'self'` and a same-origin frame is correct — the one configuration in which the bug is invisible. The new integration test renders the real app with `SANDBOX_ORIGIN` set, takes the `src` the page actually shipped, resolves it, and holds it against the CSP that arrived in the same response; then does it again with no sandbox host. It walks every HTML template rather than one, so the next frame added to this page cannot be same-origin-only either, and it asserts the frame's own `frame-ancestors` names the app host back — framing needs both sides to agree and each is a policy on a separate response. Proved it has teeth rather than assuming: against the unfixed code it fails with "expected 'https://agentartifact.example.test' to be 'https://usercontent.example.test'", "daily-digest embeds https://agentartifact.example.test/templates/daily-digest/frame, which its own frame-src blocks", and the guard assertion. Co-Authored-By: CRHQ <noreply@crhq.ai>
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 bug
The public template gallery's preview frame is blocked by CSP in production.
The frame endpoint is healthy —
/templates/<slug>/framereturns 200 with valid HTML for all 16 HTML templates. It is never fetched. The detail page embeddedsrc="/templates/${slug}/frame", a relative URL and therefore same-origin, while the app origin'sframe-srcon cloud names the sandbox host and nothing else.Self-hosted has no
SANDBOX_ORIGIN, soframe-srcfalls back to'self'and the same relative URL loads. It works on our dev box and cannot work on cloud.The fix
src/lib/template-frame.tsmakes the same single choiceViewerService.frameUrl()andownerPreviewFrameUrl()already make:sandboxOrigin ?? baseUrl. Self-hosted stays same-origin and unchanged; cloud points at the sandbox host. One code path serves both; nothing is conditional except the origin.Explicitly not re-admitting
'self'to the app origin'sframe-src.src/lib/preview-token.tsdocuments why: it would let framed HTML run scripts on the origin holding the owner's session cookie, which is exactly the risk the sandbox host exists to remove.The sandbox host had to be told
isSandboxAllowedPathtrims the sandbox host to the paths meant to answer there, and/templates/:slug/framewas not one of them. Verified against the unfixed app:GET https://usercontent.example.test/templates/project-plan/frame→ 404 Not found, from our own guard, before nginx is ever consulted. So the fixed URL needed the guard entry too.It is the third entry and the only one with no token in it, and needs none: a starter template is ours, is already served in full to any authenticated agent through
GET /v1/templates/:slug, and is linked from a page with no account behind it. The pattern is imported from where the URL is built, so the host that answers and the page that embeds cannot describe different paths.Why the suite missed it
Every existing test runs self-hosted, where
frame-srcis'self'and a same-origin frame is correct — the one configuration in which this bug is invisible. A test that only covers that configuration cannot catch this class.tests/integration/template-frame-origin.test.tsrenders the real app withSANDBOX_ORIGINset, takes thesrcthe page actually shipped, resolves it, and holds it against the CSP that arrived in the same response — then does it again with no sandbox host. It walks every HTML template rather than one, so the next frame added to this page cannot be same-origin-only either, and it asserts the frame's ownframe-ancestorsnames the app host back, because framing needs both sides to agree and each is a policy on a separate response.Failing before the fix
Passing after
Verification
pnpm check— exit 0pnpm exec vitest run— 1079 passed, 12 skipped. The 2 failures intests/integration/image-layout-runtime.test.tsare pre-existing on main (staledist/predating the manifestcategoryfield; confirmed by running that file against an unmodified tree).For deployment
The final URL path shape is unchanged from what the route already serves — only its origin moves:
Matching nginx allowlist regex (mirrors
SLUG_PATTERN, the shared slug validator):No query string, no token, no trailing slash.