Skip to content

feat(web): limit Infra embeds to the app's allowed sites - #4205

Open
richiemcilroy wants to merge 7 commits into
mainfrom
feat/infra-embed-allowed-domains
Open

richiemcilroy wants to merge 7 commits into
mainfrom
feat/infra-embed-allowed-domains

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Cap Infra apps have a list of allowed sites, but nothing enforced it. Now it limits who can embed the player.

What changes

  • Embed page. When an app has allowed sites, /dev/<playbackId>/embed sends Content-Security-Policy: frame-ancestors 'self' <sites>. The header is set in proxy.ts after one indexed lookup (developer_videos.playbackId joined to developer_app_domains.appId). If a policy header already exists, the directive is merged into it; the code never adds a second header.
  • Empty list. The response object is returned untouched, so the headers match main exactly.
  • Stored entries. Only exact https origins, or http://localhost / http://127.0.0.1 for testing, are emitted. Wildcards, paths, other http origins and malformed rows are skipped. If a list is set but nothing in it is usable, framing stays limited to 'self', so a bad row never widens access.
  • Lookup failure. If the lookup throws, the embed is served as it is today and the error is logged.
  • Adding a site. The console action and the agent API addDeveloperDomain now refuse non-local http, because it could never be honoured. Upload corsOrigin validation is unchanged.
  • Console. The "Allowed sites" section is back in app settings (it was removed in af621ca when the list did nothing). It reads: "Only these sites can embed your player. With none added, any site can. Share links, streams and downloads stay open to everyone."
  • Docs, CLI and MCP. A new "Allowed Sites" subsection in the REST docs' embed section. The CLI help and MCP tool descriptions now say what the list does.

What does not change

  • The hosted page /dev/<playbackId>, /s/, /embed/ and every other page never get the header. Tests cover these paths with a list set: no lookup and no header.
  • HLS, MP4 and poster URLs live under /api, which the proxy matcher excludes, so playback is never gated, and nothing reads Referer.
  • A frame that is refused never loads, so it sends no postMessage events, including error. The docs say so.
  • The embed response is private, no-store (checked on production), so no shared cache can serve one app's header for another.

Tests

  • infra-embed-ancestors.test.ts:
    • empty list, one site, several sites, deduplication and localhost
    • 16 bad stored entries skipped, including wildcards, CSP injection attempts, paths, uppercase and http
    • fail-closed 'self'
    • merging with an existing policy, replacing an existing directive, path matching and normalizeEmbedOrigin
  • proxy-embed-ancestors.test.ts, a real proxy with only the DB mocked:
    • empty list gives a header list equal to the hosted page's and to [["x-middleware-next","1"]]
    • one site, several sites, the RSC request (one policy header) and a failed lookup (untouched)
    • no lookup and no header for /dev/<id>, /s/, /embed/, / and /pricing
  • developer-actions.test.ts: plain http site refused. infra-console-round-trips.test.ts: the settings page reads the domains for that app only.
  • Full apps/web unit suite: 9665 passed. The 7 share-stage-identity failures also fail on main (local Node 26). tsc -b: 0 errors.

Preview and production proofs follow below.

RetriggerView in GreptileConfidence Score: 3/5

Fix the encoded-URL bypass and satisfy the client action rule before merging.

Findings

  1. P1 Security Encoded URLs bypass allowed sites ▶
  2. P2 Site removal skips shared tracing ▶
Fix with agent prompt
### Issue 1
apps/web/lib/developer-embed-origins.ts:29-30
Replacing the first `p` in a valid playback ID with `%70` makes `DEVELOPER_EMBED_PATH` miss the request. This function then returns without a policy header. Next.js decodes the route parameter and still serves the same embed, so an unlisted site can frame the restricted player.

Decode the playback-ID segment before matching and looking it up, and add a regression test.

**How this was verified:** The proxy matches the raw URL pathname, while the embed page passes the decoded dynamic route parameter to the player lookup.

### Issue 2
apps/web/app/(infra)/dashboard/infra/_components/domain-row.tsx:21-22
The new `DomainRow` uses `useMutation` directly. The client rule in `AGENTS.md` requires `useEffectMutation` from `@/lib/EffectRuntime` so actions use the shared runtime and tracing. Use that hook with `runAction`, as `DomainsSection` already does. This repository requirement must be satisfied before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

The PR adds allowed-site checks to Infra embeds and restores the settings controls.

  • Allowed sites now limit who can frame an Infra player.
  • App settings show and manage the saved allowed sites.

Intentional behavior stated by richiemcilroy: lookup failures leave embeds unrestricted; share links and media URLs remain open; stored non-local HTTP entries are skipped. These were not reported as defects.

Reviews (1) · Last reviewed commit: "docs: say the allowed sites list control..." · Reviewed by Greptile

Richie McIlroy added 4 commits November 13, 2026 08:23
When an app has allowed sites, /dev/<playbackId>/embed sends
Content-Security-Policy: frame-ancestors 'self' plus each listed https
origin. An app with no sites gets the response untouched. Entries that
are not exact https origins (or localhost) are skipped, and a list with
nothing usable keeps framing to Cap's own pages.
@vercel

vercel Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Nov 13, 2026 1:35pm UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found 1 potential issue (1 medium).

View 1 other finding in Capy.

Open in Capy

Comment thread apps/web/lib/developer-embed-origins.ts Outdated
Comment on lines +29 to +30
const playbackId = DEVELOPER_EMBED_PATH.exec(path)?.[1];
if (!playbackId) return response;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Encoded playback IDs bypass the embed restriction

Medium Severity

With allowed sites configured, an unlisted site can frame /dev/%70layback-id-0123456789ab/embed instead of the equivalent canonical URL. proxy takes the raw URL.pathname, so the percent escape fails DEVELOPER_EMBED_PATH and this early return skips both the lookup and CSP header. Next.js decodes the dynamic playbackId parameter, and the embed page passes that valid decoded ID to loadDeveloperVideoPlayer, rendering the same player without restrictions. Safely decode the playback-ID segment before matching and querying, or otherwise enforce the policy for every URL representation that resolves to the embed; add an encoded-ID regression test.

Open in Capy

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9a1f783. Production routing decodes the playback id and matches the dev and embed segments case-insensitively (checked: /dev/%72D8.../embed and /Dev/.../Embed both serve the player, encoded static segments 404, slash variants 308 to the canonical URL). embedPlaybackId now decodes the id segment and compares the static segments case-insensitively, and the proxy tests cover /dev/%70.../embed, /Dev/.../Embed and /DEV/.../EMBED.

Comment thread apps/web/lib/developer-embed-origins.ts Outdated
Comment on lines +29 to +30
const playbackId = DEVELOPER_EMBED_PATH.exec(path)?.[1];
if (!playbackId) return response;

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.

P1 security Encoded URLs bypass allowed sites

Replacing the first p in a valid playback ID with %70 makes DEVELOPER_EMBED_PATH miss the request. This function then returns without a policy header. Next.js decodes the route parameter and still serves the same embed, so an unlisted site can frame the restricted player.

Decode the playback-ID segment before matching and looking it up, and add a regression test.

How this was verified: The proxy matches the raw URL pathname, while the embed page passes the decoded dynamic route parameter to the player lookup.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/developer-embed-origins.ts
Line: 29-30

Comment:
**Encoded URLs bypass allowed sites**

Replacing the first `p` in a valid playback ID with `%70` makes `DEVELOPER_EMBED_PATH` miss the request. This function then returns without a policy header. Next.js decodes the route parameter and still serves the same embed, so an unlisted site can frame the restricted player.

Decode the playback-ID segment before matching and looking it up, and add a regression test.

**How this was verified:** The proxy matches the raw URL pathname, while the embed page passes the decoded dynamic route parameter to the player lookup.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 9a1f783. Production routing decodes the playback id and matches the dev and embed segments case-insensitively (checked: /dev/%72D8.../embed and /Dev/.../Embed both serve the player, encoded static segments 404, slash variants 308 to the canonical URL). embedPlaybackId now decodes the id segment and compares the static segments case-insensitively, and the proxy tests cover /dev/%70.../embed, /Dev/.../Embed and /DEV/.../EMBED.

Comment on lines +21 to +22
const removeMutation = useMutation({
mutationFn: () => removeDeveloperDomain(appId, domainId),

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.

P2 Site removal skips shared tracing

The new DomainRow uses useMutation directly. The client rule in AGENTS.md requires useEffectMutation from @/lib/EffectRuntime so actions use the shared runtime and tracing. Use that hook with runAction, as DomainsSection already does. This repository requirement must be satisfied before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/(infra)/dashboard/infra/_components/domain-row.tsx
Line: 21-22

Comment:
**Site removal skips shared tracing**

The new `DomainRow` uses `useMutation` directly. The client rule in `AGENTS.md` requires `useEffectMutation` from `@/lib/EffectRuntime` so actions use the shared runtime and tracing. Use that hook with `runAction`, as `DomainsSection` already does. This repository requirement must be satisfied before merging.

**Context Used:** AGENTS.md ([source](https://github.com/capsoftware/cap/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 93788a8: DomainRow now uses useEffectMutation with runAction.

The lookup runs on every embed load, so a warm instance answers from
memory for 60 s. A list change reaches every embed within that minute.
Failed reads are not kept.
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 13:30 Inactive

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

Richie McIlroy added 2 commits November 13, 2026 08:34
The router matches the dev and embed segments case-insensitively and
decodes the playback id, so /Dev/%70.../Embed rendered the same player
without the policy. The embed is now found from the decoded id with the
static segments compared case-insensitively. Paths that are not an embed
still take main's exact branch.
@vercel
vercel Bot temporarily deployed to Preview – cap-status November 13, 2026 13:35 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai

This branch was successfully deployed

1 active (outdated) and 1 inactive deployments
Preview – cap-status — 93788a84 Deployed Nov 13, 2026 by vercel[bot]
Preview – cap-web — 36edd704 Deployed Nov 13, 2026 by vercel[bot]
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.

1 participant