Skip to content

fix(web): sandbox the template preview against stored XSS - #248

Open
NathanTarbert wants to merge 3 commits into
mainfrom
fix/226-template-preview-xss-and-overrides
Open

fix(web): sandbox the template preview against stored XSS#248
NathanTarbert wants to merge 3 commits into
mainfrom
fix/226-template-preview-xss-and-overrides

Conversation

@NathanTarbert

@NathanTarbert NathanTarbert commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

The template preview renders in a sandboxed iframe, and a saved override is persisted and read back by every template surface. Closes #226 once the review items below are settled.

This is the web half of #238, rebased onto current main and unchanged from commit 5427278; that PR is now Community Signal only.

What's here

  • The stored-XSS sink is closed. settings/templates/page.tsx renders the preview via srcDoc in an iframe with sandbox="" — neither allow-scripts nor allow-same-origin. Asserted structurally with an exact-value test. No other dangerouslySetInnerHTML or rehype-raw sink remains in apps/web.
  • Overrides reach storage. PUT upserts templateOverride, GET returns it, the list flips DefaultCustom, the preview renders it.
  • 35 tests across templates-api.test.ts and templates-page.test.tsx. The loader, renderer and markdown pipeline execute for real; only session and Prisma are mocked.

Open items from Jerel's review

Jerel caught three things on #238, two of which are load-bearing. They are recorded here against the code they concern rather than left in the other PR's thread. Not fixed in this PR.

  1. A saved override does not reach a real email, and the UI reports that it did. sendEmail consults an override only when handed a dbLookup (shared/src/email/sender.ts:166, :171), and neither team/invite/route.ts:72 nor invite/resend/route.ts:52 passes one — so an invitee receives the on-disk copy while the editor shows a Custom badge and a success banner. Two ways to close it: pass findOverride as dbLookup at both call sites, with a test that a stored override changes what reaches the transport; or hold the badge and the unqualified banner until it is wired.
  2. The feature may be absent from the production image. templates/ is seven .md files at the repo root, .dockerignore:7 excludes *.md, and apps/web/Dockerfile's runner stage does not copy templates/. If that reading is right, findTemplatesDir() resolves to nothing in production and each fail-closed gate returns 404; the 35 tests pass because they run from apps/web, where the repo-root templates/ is present. Worth confirming against a built image — happy to be corrected here.
  3. The slug reaches the filesystem unvalidated. loader.ts:73-75 builds join(dir, \${slug}.md`)with no validation, and Next decodes percent-encoding in a dynamic segment. Admin-gated and bounded to.md, so the reachable surface is small; a /^[a-z0-9-]+$/` check at the route boundary closes it.

outpost-web suite: 732 passing.

@NathanTarbert NathanTarbert changed the title fix(web): sandbox the template preview, and wire overrides through to storage fix(web): sandbox the template preview against stored XSS Aug 26, 2026
@NathanTarbert
NathanTarbert force-pushed the fix/226-template-preview-xss-and-overrides branch from 9eebd89 to c3e38ee Compare September 4, 2026 15:03
@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown
CPK-8188 Review PR #248 — template preview sandbox + override persistence

CopilotKit/outpost#248 is the web half of CopilotKit/outpost#238, split out so each can be reviewed on its own. Commit 5427278 unchanged, rebased onto current main. #238 is now Community Signal only (three files, docs).

Covers the code behind outpost#226 — tracked here by CPK-7972, with CPK-8043 (the sandbox) and CPK-8044 (the PUT stub) both landing in this PR.

What the review already established

From the #238 pass, and worth not re-deriving:

  • The sandboxed-iframe work does close the stored-XSS sink — sandbox="" with neither allow-scripts nor allow-same-origin, via srcDoc, asserted with an exact-value test. No other dangerouslySetInnerHTML or rehype-raw sink remains in apps/web.
  • The 35 tests are real: loader, renderer and markdown pipeline execute; only session and Prisma are mocked.
  • The write path works end to end for every read surface — PUT upserts, GET returns it, the list flips DefaultCustom, the preview renders it.

Three items open, all recorded in the PR body

Not fixed. Two of the three were flagged as unsettled in #238's own body and shipped out of draft anyway, which is the reason for the split.

  1. A saved override never reaches a real email, while the UI reports it did. sendEmail consults an override only when handed a dbLookup (shared/src/email/sender.ts:166, :171), and neither team/invite/route.ts:72 nor invite/resend/route.ts:52 passes one. The invitee gets the on-disk copy, with no trace. Either pass findOverride as dbLookup at both sites with a test that a stored override changes what reaches the transport, or hold the Custom badge and the success banner until it is wired. This is the one worth gating on — it is the same silent-success shape the commit's own test header claims to fix.
  2. The feature may be absent from the production image. templates/ is seven .md files at the repo root, .dockerignore:7 excludes *.md, and apps/web/Dockerfile's runner stage never copies templates/. If so, findTemplatesDir() resolves to nothing in production and every fail-closed gate 404s — the tests pass only because they run from apps/web. Wants confirming against a built image.
  3. The slug reaches the filesystem unvalidated. loader.ts:73-75 builds join(dir, ${slug}.md) with no validation, and Next decodes percent-encoding in a dynamic segment. Admin-gated and bounded to .md, so small blast radius; a /^[a-z0-9-]+$/ check at the route boundary closes it.

outpost-web suite: 732 passing.

Deciding item 1 — wire it, or stop the UI claiming it — is what this ticket is for.

@jerelvelarde jerelvelarde left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed on its own, as promised. The sandbox is correct and I want it shipped — sandbox="" with neither allow-scripts nor allow-same-origin, srcDoc, no dangerouslySetInnerHTML or rehype-raw sink left in apps/web, asserted with an exact-value test. outpost-web is 732 passing across 53 files, and the loader/renderer/markdown pipeline really executes in them.

Two of the three items you recorded in the body are still open, and I am gating on them rather than filing them, for the reason you already named on #238: an item listed as unsettled in a PR body is an item that ships.

Item 2 is now #253 — your reading was right, and it is on main rather than caused by this PR, so it is not yours to fix here.

Blocker 1 — the slug traversal, and the existence checks do not close it

I ran this rather than reasoning about it. Against the real loadFromFilesystem:

slug="../README"            -> READ "# Outpost\n\nAI-powered support operations platform…"
slug="../CLAUDE"            -> READ "# CLAUDE.md\n\nOutpost — AI-powered customer support…"
slug="../docs/deployment"   -> READ "# Deployment Guide\n\nOutpost consists of seven services…"
slug="invite/../../README"  -> READ "# Outpost\n\nAI-powered support operations platform…"

GET /api/templates/[slug] at :36 calls loadTemplate(slug, findOverride) with no validation, and returns subject and body in the JSON. So an admin request for ../docs/deployment gets the deployment guide — service topology and env-var names — back over the API.

The part worth flagging beyond my original note: the new fail-closed checks are the traversal. route.ts:101 and preview/route.ts:59 both guard with if (!loadFromFilesystem(slug)). For every slug above, that read succeeds, so the guard passes and then approves the request. The check performs the escape it appears to prevent. That reads as safe on the page and is not.

..%2FREADME returns null at the loader, but Next decodes percent-encoding in a dynamic segment before the handler runs, so the route receives ../README and the plain form is what matters anyway.

listTemplateSlugs() already produces the allowlist — it returns exactly digest, escalation, invite, sla-breach, ticket-created, ticket-resolved, welcome. Testing membership in it at the route boundary closes this and makes the three existence checks cheaper at the same time, since a slug that passed the allowlist no longer needs a filesystem probe to prove it exists.

Blocker 2 — the editor still reports a save that changes nothing

Unchanged from #238: sendEmail consults an override only when handed a dbLookup (shared/src/email/sender.ts:166), and neither api/team/invite/route.ts:72 nor invite/resend/route.ts:52 passes one. This PR does not touch either file. So the write path works, every read surface confirms it — list flips to Custom, GET returns the override, the preview renders it — and the invitee gets the on-disk copy.

I am not asking for the wiring in this PR. Either of these clears it:

  • pass findOverride as dbLookup at both call sites, with one test asserting a stored override changes what reaches the transport; or
  • hold the claim: no Custom badge and no unqualified "Template saved successfully" until it is wired. A banner reading "Saved. Not yet used for outgoing email." is honest and costs one string.

The second is the one I would take if you want the sandbox in today, and I would rather not hold a real XSS fix while the email path gets designed. Given #253, note that neither option makes an edited template reach an invitee in production right now — which is an argument for the honest banner rather than the wiring, since the wiring would still not be true.

Smaller, all optional

  • preview/route.ts — the bodiless-POST guard keys on content-length !== '0', but a bodiless POST can arrive with no content-length at all (chunked, or a server-side new Request(url, {method:'POST'})), and that path 400s instead of previewing the stored template. The comment above it describes the intent correctly; the check is narrower than the comment.
  • Preview applies no length bound while PUT applies SUBJECT_MAX/BODY_MAX, and preview needs only a session rather than ADMIN — so a large draft.body runs interpolate and markdown unbounded. Reusing the two constants lines them up.
  • If the subject bound exists for header safety, a CR/LF check belongs next to it; length alone still allows Welcome\r\nBcc: ….
  • GET returns an override even when the filesystem template is gone (loadTemplate synthesises the meta) while PUT and preview both 404 — the one case where the surfaces disagree.
  • Reset deletes with no confirm step and renders whether or not isOverride is true.

Send the allowlist and whichever half of blocker 2 you prefer, and I will re-verify immediately — the sandbox itself is done and I have no notes on it.

…e preview

The templates settings screen previously read from the filesystem and stubbed its
writes. This makes the whole path real.

api/templates (list)
  Merges stored overrides over the filesystem defaults in a single findMany, so
  an overridden entry reports the override's subject and edit metadata and the
  list shows what is actually in effect.

api/templates/[slug]
  GET reads through loadTemplate, so an override wins over the default. PUT
  upserts for real, with type-checked validation rather than truthiness (an
  object or array in either field used to reach the upsert and surface as a
  500), length bounds on both columns since the subject becomes an email header
  downstream, a 404 for a slug with no filesystem template, and an editedBy that
  falls back to the member id when the session carries no email. DELETE uses
  deleteMany so resetting an already-default template is a no-op rather than a
  Prisma 404, and reports hadOverride so the UI can say which happened.

api/templates/[slug]/preview
  Accepts a draft { subject, body } and feeds it through the loader's lookup, so
  the draft wins over the stored row. Without this the editor sent an empty body
  and the server rendered whatever was stored, which meant an author previewed
  content they were not about to save.

settings/templates
  Failures now surface the route's own error message instead of a fixed string,
  so an empty field reads as an empty field rather than as an outage. The
  preview renders in a sandboxed iframe rather than through
  dangerouslySetInnerHTML: template bodies are author-editable and stored, so
  injecting them executed saved markup in every later viewer's session, and the
  csrf cookie has to be readable by client JS for the double-submit header.
  sandbox="" grants nothing; allow-scripts and allow-same-origin together would
  let the frame drop its own sandbox, so neither is set.

Tests: 35 across two new files, covering the draft-wins preview, the validation
and bounds, idempotent reset, and the override merge. tsc --noEmit clean.

Known gaps, tracked in review notes on the PR rather than fixed here: the two
sendEmail call sites still pass no dbLookup, so a saved override does not yet
reach a real invite email; and the slug route param is not validated against
listTemplateSlugs before it reaches the filesystem.
…ating a save

Addresses both blockers on #248.

## The slug reached the filesystem unvalidated

`loadFromFilesystem` builds `join(dir, slug + '.md')` with no validation, and
Next decodes percent-encoding in a dynamic segment before a handler runs.
Reproduced against the real loader before changing anything:

    ../README            → READ  # Outpost…
    ../CLAUDE            → READ  # CLAUDE.md…
    ../docs/deployment   → READ  # Deployment Guide…
    invite/../../README  → READ  # Outpost…

`GET /api/templates/[slug]` returns `subject` and `body` in its JSON, so a
request for `../docs/deployment` handed back the deployment guide — service
topology and env-var names — over the API.

The part worth stating plainly: the fail-closed existence checks were not an
incomplete defence, they were the traversal. `if (!loadFromFilesystem(slug))`
SUCCEEDS for every path above, so it approved the request it appeared to reject.
The check performed the escape it looked like it prevented.

`isKnownTemplateSlug` tests membership in `listTemplateSlugs()`, which already
returns exactly the seven real slugs. A traversal path is never a member however
it is spelled, and a slug that passes is already known to exist — so the three
existence probes are gone rather than supplemented. All four handlers now answer
the same way for the same input; DELETE is included not because `deleteMany` on
an unknown slug is dangerous but because GET returning 404 while DELETE reports
`reset: true` reads as one of them being wrong.

Where `templates/` is absent from the running image (outpost#253) this returns
false for everything and callers 404 — the behaviour those callers already had,
failing in the safe direction.

## The editor claimed a save it does not deliver

`sendEmail` consults an override only when handed a `dbLookup`
(shared/src/email/sender.ts:166), and neither api/team/invite/route.ts:72 nor
invite/resend/route.ts:52 passes one. So the row is written, every read surface
honours it — the list, GET, the preview — and the invitee receives the on-disk
copy.

Held the claim rather than wiring the lookup, which is the option Jerel
preferred: per outpost#253 the wiring would not make an edited template reach an
invitee today either, so it would trade one untrue banner for another. The
banner now reads "Saved. Not yet used for outgoing email", the badge reads
"Custom (preview only)", and the page description says the same. Both are
pinned by tests, because the dishonest string is the shorter and more natural
one to write.

## Also

The bodiless-POST guard keyed on `content-length !== '0'`, but a bodiless POST
can arrive with no such header at all — chunked, or a server-side
`new Request(url, { method: 'POST' })` — and those got a 400 instead of a
preview. It now reads the body text and treats empty as "render what is stored".

That fix needed care: a request body can only be read once, so the first version
called `request.json()` and then `text()`, which returns empty after the stream
is consumed — an unparseable body would have read as "no body" and previewed the
stored template, reintroducing the bug this route exists to fix. The text is
read once and parsed here instead. The test stubs model that, since they
previously stubbed only `json()`.

Verification: apps/web 751 tests, typecheck 10/10, lint 10/10, test 10/10.
Mutations: reverting GET to the existence probe fails 4 traversal tests;
restoring the bare success claim fails the banner test.

Not in this PR: the smaller optional items from the review — preview's missing
length bounds, a CR/LF check on `subject`, GET answering for an override whose
filesystem template is gone, and reset having no confirm step.
@NathanTarbert
NathanTarbert force-pushed the fix/226-template-preview-xss-and-overrides branch from 472a987 to d9fc475 Compare September 8, 2026 18:34
@NathanTarbert

Copy link
Copy Markdown
Collaborator Author

Thanks Jerel — both blockers were right, and the traversal was worse than my original note. Fixed in d9fc475.

The slug traversal

I reproduced your cases against the real loader before touching anything, and then the fix against the same inputs:

slug loader reads allowlisted
../README true false
../CLAUDE true false
../docs/deployment true false
invite/../../README true false
welcome true true
invite true true

The loader still reads a traversal path — that's its nature — but nothing reaches it now.

Your point about the existence checks is the part I'd have missed on my own, and it's the reason this needed gating rather than filing: the checks were the traversal. if (!loadFromFilesystem(slug)) succeeds for every path above, so it approved the request it appeared to reject. I removed the three probes rather than adding to them, since a slug that passes membership is already known to exist.

DELETE is included too. Not because deleteMany on an unknown slug is dangerous, but for the disagreement you flagged separately: GET returning 404 while DELETE reports reset: true for the same input reads as one of them being wrong.

On #253 — where templates/ is absent from the image this returns false for everything and callers 404. Same behaviour those callers already had, and it fails in the safe direction.

The save claim

Took your second option, and your reasoning for preferring it is what decided it: per #253 the wiring wouldn't make an edited template reach an invitee today either, so it would trade one untrue banner for another.

  • banner: "Saved. Not yet used for outgoing email — see outpost#226."
  • badge: "Custom (preview only)"
  • the page description says the same

All pinned by tests, because the dishonest string is the shorter and more natural one to write.

The bodiless-POST guard

Fixed, and it needed more care than it looked. My first version read request.json() and then text() — but a request body can only be read once, so text() returns empty after json() consumes the stream. An unparseable body would have read as "no body" and previewed the stored template, which is exactly the bug this route exists to fix. It reads the text once and parses it here instead. The test stubs model that now; they previously stubbed only json().

Verification

apps/web 751 tests, typecheck 10/10, lint 10/10, test 10/10. Mutations: reverting GET to the existence probe fails 4 traversal tests, and restoring the bare success claim fails the banner test.

I also ran the changed-file format check locally before pushing this time — it caught 5 files. That's the gate from #250 earning its place twice now.

Not in this PR

The smaller optional items: preview's missing length bounds against PUT's SUBJECT_MAX/BODY_MAX, a CR/LF check on subject next to the length bound, GET answering for an override whose filesystem template is gone, and reset having no confirm step. Happy to take any of them here if you'd rather they land together — they're all small, I just didn't want to grow a security fix.

Missed by my local check, which read `git status` — uncommitted changes only —
so it did not see files changed in earlier commits on the branch. CI diffs the
whole PR against main, which is the set that matters. Checking
`git diff origin/main...HEAD` instead now agrees with CI.

Whole-PR file set passes prettier; typecheck, lint and test all 10/10.
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.

security: stored XSS via unsanitized dangerouslySetInnerHTML in the template preview

2 participants