diff --git a/apps/web/package.json b/apps/web/package.json
index ebf67f722f..49a2e92a46 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -41,6 +41,7 @@
"@open-design/sidecar-proto": "workspace:*",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
+ "dompurify": "3.4.2",
"jspdf": "4.2.1",
"lexical": "0.36.2",
"lucide-react": "1.16.0",
diff --git a/apps/web/src/runtime/deck-thumbnail-parser.ts b/apps/web/src/runtime/deck-thumbnail-parser.ts
index efbb2f723a..1894f5905c 100644
--- a/apps/web/src/runtime/deck-thumbnail-parser.ts
+++ b/apps/web/src/runtime/deck-thumbnail-parser.ts
@@ -17,6 +17,8 @@
// report `renderable: false` with a reason, and the caller keeps the old
// iframe thumbnail for that deck.
+import DOMPurify from 'dompurify';
+
import { DECK_SLIDE_SELECTOR } from '@open-design/contracts/runtime/deck-stage-fallback';
export type DeckThumbnailFallbackReason =
@@ -67,7 +69,29 @@ const STRUCTURED_SLIDE_SELECTOR =
'deck-stage > [data-screen-label], .deck-stage > [data-screen-label], ' +
'#deck > [data-screen-label], body > [data-screen-label]';
-const FONT_HOST_RE = /(?:fonts\.googleapis\.com|fonts\.gstatic\.com|use\.typekit\.net|fonts\.bunny\.net|fonts\.cdnfonts\.com)/i;
+const FONT_HOSTS = new Set([
+ 'fonts.googleapis.com',
+ 'fonts.gstatic.com',
+ 'use.typekit.net',
+ 'fonts.bunny.net',
+ 'fonts.cdnfonts.com',
+]);
+
+// A font stylesheet link is re-loaded document-wide by DeckSlideThumbnail, so it
+// must be an https URL whose HOST is exactly an approved font CDN — a substring
+// match would accept `https://evil.example/fonts.googleapis.com.css` and inject
+// arbitrary CSS into the app document.
+function isApprovedFontHref(href: string): boolean {
+ // Font-CDN links are always absolute https URLs; a relative href cannot be an
+ // approved CDN and is correctly treated as an untrusted external stylesheet.
+ let url: URL;
+ try {
+ url = new URL(href);
+ } catch {
+ return false;
+ }
+ return url.protocol === 'https:' && FONT_HOSTS.has(url.hostname.toLowerCase());
+}
function unrenderable(reason: DeckThumbnailFallbackReason): ParsedDeckThumbnails {
return {
@@ -107,7 +131,7 @@ export function parseDeckThumbnails(html: string, baseHref?: string): ParsedDeck
if (!/\bstylesheet\b/.test(rel)) continue;
const href = link.getAttribute('href') || '';
if (!href) continue;
- if (FONT_HOST_RE.test(href)) {
+ if (isApprovedFontHref(href)) {
if (!fontLinks.includes(href)) fontLinks.push(href);
} else {
return unrenderable('external-stylesheet');
@@ -195,10 +219,10 @@ function collectAncestors(slide: Element): DeckThumbnailAncestor[] {
while (node) {
const tag = node.tagName.toLowerCase();
if (tag === 'body' || tag === 'html') break;
- chain.push({
- tag,
- attributes: Array.from(node.attributes).map((a) => [a.name, a.value] as [string, string]),
- });
+ // These wrappers are reconstructed as live elements in the app-origin shadow
+ // DOM by DeckSlideThumbnail, so a wrapper is a second injection path for
+ // untrusted deck markup and is sanitized the same way as the slide body.
+ chain.push(sanitizeThumbnailAncestor(node));
node = node.parentElement;
}
return chain.reverse();
@@ -295,13 +319,72 @@ function absolutizeCssUrls(css: string, baseHref: string): string {
});
}
-// Clone the slide and normalize it for shadow rendering: rewrite inline-style
-// viewport units to canvas px, and (when a base href is known) rewrite relative
-// asset references (`src`, `srcset`, inline `style` url(), `href`) to absolute
-// — a shadow root carries no