Skip to content

fix(templates): frame the gallery preview from the sandbox origin - #26

Merged
scheemunai merged 1 commit into
mainfrom
fix/template-frame-sandbox-origin
Sep 5, 2026
Merged

fix(templates): frame the gallery preview from the sandbox origin#26
scheemunai merged 1 commit into
mainfrom
fix/template-frame-sandbox-origin

Conversation

@scheemunai

Copy link
Copy Markdown
Owner

The bug

The public template gallery's preview frame is blocked by CSP in production.

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 frame endpoint is healthy — /templates/<slug>/frame returns 200 with valid HTML for all 16 HTML templates. It is never fetched. The detail page embedded src="/templates/${slug}/frame", a relative URL and therefore same-origin, while the app origin's frame-src on cloud names the sandbox host and nothing else.

Self-hosted has no SANDBOX_ORIGIN, so frame-src falls 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.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. One code path serves both; nothing is conditional except the origin.

Explicitly not re-admitting 'self' to the app origin's frame-src. src/lib/preview-token.ts documents 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

isSandboxAllowedPath trims the sandbox host to the paths meant to answer there, and /templates/:slug/frame was not one of them. Verified against the unfixed app: GET https://usercontent.example.test/templates/project-plan/frame404 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-src is '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.ts 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, because framing needs both sides to agree and each is a policy on a separate response.

Failing before the fix

FAIL > points the frame at the sandbox host when the deployment has one
AssertionError: expected 'https://agentartifact.example.test' to be 'https://usercontent.example.test'

FAIL > ships no HTML template whose frame its own response would refuse to load
AssertionError: daily-digest embeds https://agentartifact.example.test/templates/daily-digest/frame,
which its own frame-src blocks (sandboxOrigin=https://usercontent.example.test)

FAIL > is allowed by the sandbox host guard, not just by the CSP
AssertionError: expected false to be true

Tests  3 failed | 2 passed (5)

Passing after

Test Files  1 passed (1)
     Tests  5 passed (5)

Verification

  • pnpm check — exit 0
  • pnpm exec vitest run — 1079 passed, 12 skipped. The 2 failures in tests/integration/image-layout-runtime.test.ts are pre-existing on main (stale dist/ predating the manifest category field; 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:

https://usercontent.agentartifact.ai/templates/<slug>/frame

Matching nginx allowlist regex (mirrors SLUG_PATTERN, the shared slug validator):

^/templates/[a-z0-9]+(?:-[a-z0-9]+)*/frame$

No query string, no token, no trailing slash.

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>
@scheemunai
scheemunai merged commit 6197a0b into main Sep 5, 2026
5 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