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 , so relative URLs would otherwise resolve -// against the host app page. +// DOMPurify configuration for a deck THUMBNAIL. DOMPurify's default profile +// already removes ', + ' ', + ' ', + ' ', + '
', + ' ', + ' ', + '', + ].join('\n'); + const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/'); + expect(parsed.renderable).toBe(true); + const slide = parsed.slides[0] ?? ''; + // no inline event handlers, executable/navigable elements, or script URLs + expect(slide).not.toMatch(/onerror/i); + expect(slide).not.toMatch(/onclick/i); + expect(slide).not.toMatch(/