Repository navigation
feat(web): limit Infra embeds to the app's allowed sites - #4205
richiemcilroy wants to merge 7 commits into
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
| const playbackId = DEVELOPER_EMBED_PATH.exec(path)?.[1]; | ||
| if (!playbackId) return response; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const playbackId = DEVELOPER_EMBED_PATH.exec(path)?.[1]; | ||
| if (!playbackId) return response; |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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.
| const removeMutation = useMutation({ | ||
| mutationFn: () => removeDeveloperDomain(appId, domainId), |
There was a problem hiding this 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)
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!
There was a problem hiding this comment.
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.
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.
Cap Infra apps have a list of allowed sites, but nothing enforced it. Now it limits who can embed the player.
What changes
/dev/<playbackId>/embedsendsContent-Security-Policy: frame-ancestors 'self' <sites>. The header is set inproxy.tsafter one indexed lookup (developer_videos.playbackIdjoined todeveloper_app_domains.appId). If a policy header already exists, the directive is merged into it; the code never adds a second header.http://localhost/http://127.0.0.1for 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.addDeveloperDomainnow refuse non-local http, because it could never be honoured. UploadcorsOriginvalidation is unchanged.What does not change
/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./api, which the proxy matcher excludes, so playback is never gated, and nothing readsReferer.postMessageevents, includingerror. The docs say so.private, no-store(checked on production), so no shared cache can serve one app's header for another.Tests
infra-embed-ancestors.test.ts:'self'normalizeEmbedOriginproxy-embed-ancestors.test.ts, a real proxy with only the DB mocked:[["x-middleware-next","1"]]/dev/<id>,/s/,/embed/,/and/pricingdeveloper-actions.test.ts: plain http site refused.infra-console-round-trips.test.ts: the settings page reads the domains for that app only.apps/webunit suite: 9665 passed. The 7share-stage-identityfailures also fail on main (local Node 26).tsc -b: 0 errors.Preview and production proofs follow below.
Fix the encoded-URL bypass and satisfy the client action rule before merging.
Findings
Fix with agent prompt
Summary
The PR adds allowed-site checks to Infra embeds and restores the settings controls.
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