Skip to content

Commit 47073b8

Browse files
committed
fix(web/embed): tighten media-embed surface — audit findings
Audit fixes from the post-be52cc1 9-dim review: - CSS selector for the post-detail UrlAutoEmbed now targets the .proto-row sibling directly. Previous selector targeted descendants inside the sibling, so the wrapper class never matched and the player rendered unstyled. - Drop the allowYoutube alias on RenderMarkdownOptions. The alias silently widened semantics from "YouTube only" to all three platforms, which is a regression for any caller that opts in by the old name. Only allowMediaEmbeds remains; both call sites (MarkdownEditor preview, post-detail body render) migrated. - Drop allow-same-origin from the Spotify and Apple iframe sandboxes. Both load cookie-bearing hosts; without allow-same-origin the iframe is forced into a unique opaque origin so it can't read the reader's session cookies. The player ↔ parent communication uses postMessage which is cross-origin safe. YouTube keeps allow-same-origin because youtube-nocookie.com is the canonical privacy host and removing it breaks the embed player controls. - Centralize per-platform iframe attrs into lib/embed-attrs.ts so the markdown sanitizer, the three pre-pass builders, and UrlAutoEmbed all consume one source of truth. Future tightening lands in one edit. - End-anchor the Spotify path regex; reject malformed Apple ?i= values instead of silently downgrading to a show embed. - Strict CommonMark fence-close detection across all three embed-pre-pass walkers. A line like `~~~more` inside a fence body no longer terminates it; closers must carry no info string. Tests: existing suite green; the spotify/apple/markdown-media tests reflect the new sandbox shape. Two findings deferred: - UrlAutoEmbed component-level test (needs project-wide React test infrastructure not yet present) - tokens.css migration (codebase uses theme.css for :root by convention; project-wide migration is out of scope)
1 parent be52cc1 commit 47073b8

12 files changed

Lines changed: 276 additions & 207 deletions

web/src/components/prototype/MarkdownEditor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ export function MarkdownEditor({
196196
return;
197197
}
198198
let cancelled = false;
199-
renderMarkdown(value, { allowYoutube: kind === "submission" }).then(
199+
renderMarkdown(value, { allowMediaEmbeds: kind === "submission" }).then(
200200
(html) => {
201201
if (!cancelled) setPreviewHtml(html);
202202
},

web/src/components/prototype/UrlAutoEmbed.tsx

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,26 @@
1111
* "open in new tab" / share.
1212
*
1313
* Source data is never mutated. The component derives the iframe
14-
* directly from `submission.url` at render time. Edits to the URL
15-
* (or future changes to embed src patterns) propagate without a
16-
* backfill.
14+
* directly from `submission.url` at render time.
15+
*
16+
* Iframe attribute sets are imported from lib/embed-attrs.ts — the
17+
* same source the markdown sanitizer uses, so the in-body and
18+
* post-detail surfaces can't drift on sandbox/referrer/allow.
1719
*
1820
* Privacy posture mirrors the in-body markdown embeds:
1921
* - YouTube goes through youtube-nocookie.com.
2022
* - Spotify and Apple don't have nocookie equivalents — the embeds
21-
* do load each platform's session cookies if the reader is
22-
* logged in there. We mitigate with sandbox + strict referrer
23-
* policy. The same trade-off exists on every site embedding
24-
* these platforms. See lib/markdown.ts for the parallel pattern
25-
* applied to in-body URLs.
23+
* do load each platform's pages, but with allow-same-origin
24+
* dropped from the sandbox the iframe can't read those hosts'
25+
* session cookies. See lib/embed-attrs.ts for the trade-off.
2626
*/
2727

2828
import { extractApplePodcastsMatch } from "@/lib/apple-podcasts-embed";
29+
import {
30+
APPLE_PODCASTS_IFRAME_ATTRS,
31+
SPOTIFY_IFRAME_ATTRS,
32+
YT_IFRAME_ATTRS,
33+
} from "@/lib/embed-attrs";
2934
import { extractSpotifyMatch } from "@/lib/spotify-embed";
3035
import { extractYoutubeId } from "@/lib/youtube-embed";
3136

@@ -42,11 +47,11 @@ export function UrlAutoEmbed({ url }: Props) {
4247
<div className="proto-yt-embed">
4348
<iframe
4449
src={`https://www.youtube-nocookie.com/embed/${youtubeId}`}
45-
title="YouTube video"
46-
loading="lazy"
47-
referrerPolicy="strict-origin-when-cross-origin"
48-
sandbox="allow-scripts allow-same-origin allow-presentation"
49-
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
50+
title={YT_IFRAME_ATTRS.title}
51+
loading={YT_IFRAME_ATTRS.loading}
52+
referrerPolicy={YT_IFRAME_ATTRS.referrerpolicy}
53+
sandbox={YT_IFRAME_ATTRS.sandbox}
54+
allow={YT_IFRAME_ATTRS.allow}
5055
allowFullScreen
5156
/>
5257
</div>
@@ -59,11 +64,11 @@ export function UrlAutoEmbed({ url }: Props) {
5964
<div className="proto-spotify-embed">
6065
<iframe
6166
src={`https://open.spotify.com/embed/${spotify.kind}/${spotify.id}`}
62-
title="Spotify embed"
63-
loading="lazy"
64-
referrerPolicy="strict-origin-when-cross-origin"
65-
sandbox="allow-scripts allow-same-origin allow-popups"
66-
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
67+
title={SPOTIFY_IFRAME_ATTRS.title}
68+
loading={SPOTIFY_IFRAME_ATTRS.loading}
69+
referrerPolicy={SPOTIFY_IFRAME_ATTRS.referrerpolicy}
70+
sandbox={SPOTIFY_IFRAME_ATTRS.sandbox}
71+
allow={SPOTIFY_IFRAME_ATTRS.allow}
6772
/>
6873
</div>
6974
);
@@ -77,11 +82,11 @@ export function UrlAutoEmbed({ url }: Props) {
7782
<div className="proto-applepod-embed">
7883
<iframe
7984
src={src}
80-
title="Apple Podcasts embed"
81-
loading="lazy"
82-
referrerPolicy="strict-origin-when-cross-origin"
83-
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
84-
allow="autoplay; encrypted-media; fullscreen"
85+
title={APPLE_PODCASTS_IFRAME_ATTRS.title}
86+
loading={APPLE_PODCASTS_IFRAME_ATTRS.loading}
87+
referrerPolicy={APPLE_PODCASTS_IFRAME_ATTRS.referrerpolicy}
88+
sandbox={APPLE_PODCASTS_IFRAME_ATTRS.sandbox}
89+
allow={APPLE_PODCASTS_IFRAME_ATTRS.allow}
8590
/>
8691
</div>
8792
);

web/src/lib/apple-podcasts-embed.ts

Lines changed: 49 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,15 @@
1919
* markdown.ts re-validates the src via APPLE_PODCASTS_EMBED_SRC.
2020
*
2121
* Privacy note: Apple does not provide a nocookie-equivalent host.
22-
* The embed loads embed.podcasts.apple.com which can read Apple's
23-
* session cookies. Same trade-off as Spotify — mitigated by sandbox +
24-
* referrerpolicy, but cookies still flow if the reader is logged
25-
* into Apple in the same browser.
22+
* The embed loads embed.podcasts.apple.com, where the reader may
23+
* have a session cookie. By dropping `allow-same-origin` from the
24+
* sandbox (see lib/embed-attrs.ts) the iframe is forced into a
25+
* unique opaque origin and cannot access those cookies. Same
26+
* trade-off and mitigation as Spotify.
2627
*/
2728

29+
import { APPLE_PODCASTS_IFRAME_ATTRS } from "@/lib/embed-attrs";
30+
2831
const COUNTRY = /^[a-z]{2}$/;
2932
const NUMERIC_ID = /^\d+$/;
3033

@@ -72,31 +75,36 @@ export function extractApplePodcastsMatch(
7275
const [, country, slug, showId] = m;
7376
if (!COUNTRY.test(country) || !NUMERIC_ID.test(showId)) return null;
7477

75-
// ?i=<episode-id> is the only query param we honor.
76-
const episodeRaw = parsed.searchParams.get("i");
77-
const episodeId =
78-
episodeRaw && NUMERIC_ID.test(episodeRaw) ? episodeRaw : null;
78+
// ?i=<episode-id> is the only query param we honor. If it's
79+
// present but not numeric, the user signaled episode intent with a
80+
// malformed value — reject the whole URL rather than silently
81+
// downgrading to a show-level embed.
82+
if (parsed.searchParams.has("i")) {
83+
const episodeRaw = parsed.searchParams.get("i") ?? "";
84+
if (!NUMERIC_ID.test(episodeRaw)) return null;
85+
return { country, slug, showId, episodeId: episodeRaw };
86+
}
7987

80-
return { country, slug, showId, episodeId };
88+
return { country, slug, showId, episodeId: null };
8189
}
8290

83-
/** Build the iframe block for a given match. */
91+
/** Build the iframe block for a given match. Iframe attrs come from
92+
* lib/embed-attrs.ts so the in-body and post-detail surfaces share
93+
* one source of truth. Apple's official embed sandbox is famously
94+
* permissive; we deliberately tighten in the shared module — if a
95+
* show breaks playback, loosen there, not here. */
8496
function buildEmbed(match: ApplePodcastsMatch): string {
8597
const base = `https://embed.podcasts.apple.com/${match.country}/podcast/${match.slug}/id${match.showId}`;
8698
const src = match.episodeId ? `${base}?i=${match.episodeId}` : base;
87-
// Apple's official embed sandbox is famously permissive
88-
// (allow-storage-access-by-user-activation, allow-top-navigation,
89-
// etc.). We deliberately tighten — readers don't need the embed
90-
// to navigate the host page or escape its sandbox. If the player
91-
// breaks for some content shape, expand on a case-by-case basis.
99+
const a = APPLE_PODCASTS_IFRAME_ATTRS;
92100
return (
93101
`<div class="proto-applepod-embed">` +
94102
`<iframe src="${src}"` +
95-
` title="Apple Podcasts embed"` +
96-
` loading="lazy"` +
97-
` referrerpolicy="strict-origin-when-cross-origin"` +
98-
` sandbox="allow-scripts allow-same-origin allow-popups allow-forms"` +
99-
` allow="autoplay; encrypted-media; fullscreen"` +
103+
` title="${a.title}"` +
104+
` loading="${a.loading}"` +
105+
` referrerpolicy="${a.referrerpolicy}"` +
106+
` sandbox="${a.sandbox}"` +
107+
` allow="${a.allow}"` +
100108
`></iframe>` +
101109
`</div>`
102110
);
@@ -117,24 +125,33 @@ export function rewriteApplePodcastsEmbeds(source: string): string {
117125
const URL_LINE = /^ {0,3}(https?:\/\/\S+)[ \t]*$/;
118126

119127
for (const line of lines) {
120-
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
121-
if (fenceMatch) {
122-
const marker = fenceMatch[1];
123-
const ch = marker[0] as "`" | "~";
124-
if (!inFence) {
128+
// Fence open/close detection — see youtube-embed.ts for the
129+
// CommonMark rules. Strict close (no info string) prevents
130+
// a line like `~~~more` inside a fence body from closing it.
131+
if (inFence) {
132+
const closeMatch = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
133+
if (closeMatch) {
134+
const marker = closeMatch[1];
135+
const ch = marker[0] as "`" | "~";
136+
if (ch === fenceChar && marker.length >= fenceLen) {
137+
inFence = false;
138+
fenceChar = null;
139+
fenceLen = 0;
140+
out.push(line);
141+
continue;
142+
}
143+
}
144+
} else {
145+
const openMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
146+
if (openMatch) {
147+
const marker = openMatch[1];
148+
const ch = marker[0] as "`" | "~";
125149
inFence = true;
126150
fenceChar = ch;
127151
fenceLen = marker.length;
128152
out.push(line);
129153
continue;
130154
}
131-
if (ch === fenceChar && marker.length >= fenceLen) {
132-
inFence = false;
133-
fenceChar = null;
134-
fenceLen = 0;
135-
out.push(line);
136-
continue;
137-
}
138155
}
139156

140157
if (inFence) {

web/src/lib/embed-attrs.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Single source of truth for the per-platform iframe attribute sets
3+
* used by media embeds (YouTube, Spotify, Apple Podcasts).
4+
*
5+
* Two consumers must agree on these:
6+
* 1. The markdown sanitizer in lib/markdown.ts re-stamps each
7+
* surviving iframe's attrs to the matching set, so a hand-rolled
8+
* iframe can't sneak in a relaxed sandbox.
9+
* 2. The UrlAutoEmbed component on the post-detail page emits the
10+
* same iframe directly from submission.url, never going through
11+
* the markdown pipeline.
12+
*
13+
* Centralizing here lets a tightening (or platform-specific quirk
14+
* fix) land in one edit and propagate everywhere. Drift between the
15+
* two consumers is the failure mode the centralization is preventing.
16+
*
17+
* Sandbox posture: minimal permissions. allow-same-origin is
18+
* deliberately NOT granted to Spotify or Apple — those embeds run
19+
* on cookie-bearing hosts (open.spotify.com, embed.podcasts.apple.com)
20+
* where the reader may be logged in. Dropping allow-same-origin
21+
* forces the iframe into a unique opaque origin so it can't read
22+
* those cookies. The embed players communicate with the parent via
23+
* postMessage, which works cross-origin without same-origin. YouTube
24+
* gets allow-same-origin because youtube-nocookie.com is the
25+
* canonical privacy-aware embed host (no Google-account cookies live
26+
* there), so the same-origin combo doesn't grant access to anything
27+
* sensitive — and removing it breaks YouTube's embed playback
28+
* controls.
29+
*/
30+
31+
export const YT_IFRAME_ATTRS = {
32+
title: "YouTube video",
33+
loading: "lazy",
34+
referrerpolicy: "strict-origin-when-cross-origin",
35+
sandbox: "allow-scripts allow-same-origin allow-presentation",
36+
allow:
37+
"accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
38+
allowfullscreen: "",
39+
} as const;
40+
41+
export const SPOTIFY_IFRAME_ATTRS = {
42+
title: "Spotify embed",
43+
loading: "lazy",
44+
referrerpolicy: "strict-origin-when-cross-origin",
45+
// No allow-same-origin: see file header. allow-popups is kept for
46+
// the "Open in Spotify" button to work in a new tab.
47+
sandbox: "allow-scripts allow-popups",
48+
allow:
49+
"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture",
50+
} as const;
51+
52+
export const APPLE_PODCASTS_IFRAME_ATTRS = {
53+
title: "Apple Podcasts embed",
54+
loading: "lazy",
55+
referrerpolicy: "strict-origin-when-cross-origin",
56+
// No allow-same-origin: see file header. allow-popups for "Open in
57+
// Apple Podcasts" link; allow-forms for the player's internal
58+
// controls (Apple's official sandbox grants both).
59+
sandbox: "allow-scripts allow-popups allow-forms",
60+
allow: "autoplay; encrypted-media; fullscreen",
61+
} as const;

web/src/lib/markdown.ts

Lines changed: 6 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import sanitizeHtml from "sanitize-html";
1212

1313
import { highlightCodeToLines } from "@/lib/highlight";
1414
import { rewriteApplePodcastsEmbeds } from "@/lib/apple-podcasts-embed";
15+
import {
16+
APPLE_PODCASTS_IFRAME_ATTRS,
17+
SPOTIFY_IFRAME_ATTRS,
18+
YT_IFRAME_ATTRS,
19+
} from "@/lib/embed-attrs";
1520
import { rewriteSpotifyEmbeds } from "@/lib/spotify-embed";
1621
import { rewriteYoutubeEmbeds } from "@/lib/youtube-embed";
1722

@@ -32,41 +37,6 @@ const SPOTIFY_EMBED_SRC =
3237
const APPLE_PODCASTS_EMBED_SRC =
3338
/^https:\/\/embed\.podcasts\.apple\.com\/[a-z]{2}\/podcast\/[^/?#]+\/id\d+(?:\?i=\d+)?$/;
3439

35-
/** Canonical safe attribute set forced onto every surviving YouTube
36-
* iframe. We re-stamp these on every iframe (even pre-pass output)
37-
* so a hand-rolled iframe with the right SRC but missing or weakened
38-
* sandbox/referrerpolicy/loading values gets normalised to the safe
39-
* shape. */
40-
const YT_IFRAME_ATTRS = {
41-
title: "YouTube video",
42-
loading: "lazy",
43-
referrerpolicy: "strict-origin-when-cross-origin",
44-
sandbox: "allow-scripts allow-same-origin allow-presentation",
45-
allow:
46-
"accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
47-
allowfullscreen: "",
48-
} as const;
49-
50-
/** Canonical safe attribute set for Spotify iframes. Tighter sandbox
51-
* than the Spotify default — readers don't need top-navigation. */
52-
const SPOTIFY_IFRAME_ATTRS = {
53-
title: "Spotify embed",
54-
loading: "lazy",
55-
referrerpolicy: "strict-origin-when-cross-origin",
56-
sandbox: "allow-scripts allow-same-origin allow-popups",
57-
allow:
58-
"autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture",
59-
} as const;
60-
61-
/** Canonical safe attribute set for Apple Podcasts iframes. */
62-
const APPLE_PODCASTS_IFRAME_ATTRS = {
63-
title: "Apple Podcasts embed",
64-
loading: "lazy",
65-
referrerpolicy: "strict-origin-when-cross-origin",
66-
sandbox: "allow-scripts allow-same-origin allow-popups allow-forms",
67-
allow: "autoplay; encrypted-media; fullscreen",
68-
} as const;
69-
7040
export const ALLOWED_TAGS = [
7141
"p",
7242
"br",
@@ -506,23 +476,15 @@ export interface RenderMarkdownOptions {
506476
* Wire only on submission body surfaces — comments and editorial
507477
* docs should remain text-only so a media URL in a heated reply
508478
* can't visually dominate the thread.
509-
*
510-
* Backward-compat alias `allowYoutube` is honored — it now means
511-
* "allow all three platforms" since the embed pipeline grew beyond
512-
* YouTube. Prefer `allowMediaEmbeds` in new call sites.
513479
*/
514480
allowMediaEmbeds?: boolean;
515-
/** @deprecated Use `allowMediaEmbeds`. Kept so older call sites
516-
* don't drift; behavior is identical. */
517-
allowYoutube?: boolean;
518481
}
519482

520483
export async function renderMarkdown(
521484
source: string,
522485
options: RenderMarkdownOptions = {},
523486
): Promise<string> {
524-
const allowEmbeds =
525-
options.allowMediaEmbeds === true || options.allowYoutube === true;
487+
const allowEmbeds = options.allowMediaEmbeds === true;
526488
// Order matters: each rewriter is idempotent on its own output but
527489
// would re-process foreign output. YouTube's bare-URL match is
528490
// catch-all-shaped (any https://\S+ URL on its own line), so it

0 commit comments

Comments
 (0)