mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): sanitize deck thumbnail markup with DOMPurify (#5847)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user