diff --git a/crates/js/lib/package-lock.json b/crates/js/lib/package-lock.json index edc731e2..24218c58 100644 --- a/crates/js/lib/package-lock.json +++ b/crates/js/lib/package-lock.json @@ -8,6 +8,7 @@ "name": "tsjs", "version": "0.1.0", "dependencies": { + "dompurify": "3.3.2", "prebid.js": "^10.26.0" }, "devDependencies": { @@ -2989,6 +2990,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -4415,6 +4423,18 @@ "node": ">=0.10.0" } }, + "node_modules/dompurify": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dset": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", diff --git a/crates/js/lib/package.json b/crates/js/lib/package.json index 9beca211..fa16e919 100644 --- a/crates/js/lib/package.json +++ b/crates/js/lib/package.json @@ -15,6 +15,7 @@ "format:write": "prettier --write \"**/*.{ts,tsx,js,json,css,md}\"" }, "dependencies": { + "dompurify": "3.3.2", "prebid.js": "^10.26.0" }, "devDependencies": { diff --git a/crates/js/lib/src/core/render.ts b/crates/js/lib/src/core/render.ts index 42a277b9..987b5ade 100644 --- a/crates/js/lib/src/core/render.ts +++ b/crates/js/lib/src/core/render.ts @@ -1,16 +1,221 @@ // Rendering utilities for Trusted Server demo placements: find slots, seed placeholders, // and inject creatives into sandboxed iframes. +import createDOMPurify, { + type DOMPurify as DOMPurifyInstance, + type RemovedAttribute, + type RemovedElement, +} from 'dompurify'; + import { log } from './log'; import type { AdUnit } from './types'; import { getUnit, getAllUnits, firstSize } from './registry'; import NORMALIZE_CSS from './styles/normalize.css?inline'; import IFRAME_TEMPLATE from './templates/iframe.html?raw'; +const DANGEROUS_TAG_NAMES = new Set([ + 'base', + 'embed', + 'form', + 'iframe', + 'link', + 'meta', + 'object', + 'script', +]); +const URI_ATTRIBUTE_NAMES = new Set([ + 'action', + 'background', + 'formaction', + 'href', + 'poster', + 'src', + 'srcdoc', + 'xlink:href', +]); +const DANGEROUS_URI_VALUE_PATTERN = /^\s*(?:javascript:|vbscript:|data\s*:\s*text\/html\b)/i; +const DANGEROUS_STYLE_PATTERN = /\bexpression\s*\(|\burl\s*\(\s*['"]?\s*javascript:/i; +const CREATIVE_SANDBOX_TOKENS = [ + 'allow-forms', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-top-navigation-by-user-activation', +] as const; + +export type CreativeSanitizationRejectionReason = + | 'empty-after-sanitize' + | 'invalid-creative-html' + | 'removed-dangerous-content' + | 'sanitizer-unavailable'; + +export type AcceptedCreativeHtml = { + kind: 'accepted'; + originalLength: number; + sanitizedHtml: string; + sanitizedLength: number; + removedCount: number; +}; + +export type RejectedCreativeHtml = { + kind: 'rejected'; + originalLength: number; + sanitizedLength: number; + removedCount: number; + rejectionReason: CreativeSanitizationRejectionReason; +}; + +export type SanitizeCreativeHtmlResult = AcceptedCreativeHtml | RejectedCreativeHtml; + +let creativeSanitizer: DOMPurifyInstance | null | undefined; + function normalizeId(raw: string): string { const s = String(raw ?? '').trim(); return s.startsWith('#') ? s.slice(1) : s; } +function getCreativeSanitizer(): DOMPurifyInstance | null { + if (creativeSanitizer !== undefined) { + return creativeSanitizer; + } + + if (typeof window === 'undefined') { + creativeSanitizer = null; + return creativeSanitizer; + } + + try { + creativeSanitizer = createDOMPurify(window); + } catch (err) { + log.warn('sanitizeCreativeHtml: failed to initialize DOMPurify', err); + creativeSanitizer = null; + } + + return creativeSanitizer; +} + +function isDangerousRemoval(removedItem: RemovedAttribute | RemovedElement): boolean { + if ('element' in removedItem) { + const tagName = removedItem.element.nodeName.toLowerCase(); + return DANGEROUS_TAG_NAMES.has(tagName); + } + + const attrName = removedItem.attribute?.name.toLowerCase() ?? ''; + const attrValue = removedItem.attribute?.value ?? ''; + + if (attrName.startsWith('on')) { + return true; + } + + if (URI_ATTRIBUTE_NAMES.has(attrName)) { + return true; + } + + if (attrName === 'style' && DANGEROUS_STYLE_PATTERN.test(attrValue)) { + return true; + } + + return false; +} + +function hasDangerousMarkup(candidateHtml: string): boolean { + const fragment = document.createElement('template'); + // The HTML parser normalizes entity-encoded attribute values before we inspect them. + fragment.innerHTML = candidateHtml; + + for (const element of fragment.content.querySelectorAll('*')) { + const tagName = element.nodeName.toLowerCase(); + if (DANGEROUS_TAG_NAMES.has(tagName)) { + return true; + } + + if (tagName === 'style' && DANGEROUS_STYLE_PATTERN.test(element.textContent ?? '')) { + return true; + } + + for (const attrName of element.getAttributeNames()) { + const normalizedAttrName = attrName.toLowerCase(); + const attrValue = element.getAttribute(attrName) ?? ''; + + if (normalizedAttrName.startsWith('on')) { + return true; + } + + if ( + URI_ATTRIBUTE_NAMES.has(normalizedAttrName) && + DANGEROUS_URI_VALUE_PATTERN.test(attrValue) + ) { + return true; + } + + if (normalizedAttrName === 'style' && DANGEROUS_STYLE_PATTERN.test(attrValue)) { + return true; + } + } + } + + return false; +} + +// Sanitize the untrusted creative fragment before it is embedded into the trusted iframe shell. +export function sanitizeCreativeHtml(creativeHtml: unknown): SanitizeCreativeHtmlResult { + if (typeof creativeHtml !== 'string') { + return { + kind: 'rejected', + originalLength: 0, + sanitizedLength: 0, + removedCount: 0, + rejectionReason: 'invalid-creative-html', + }; + } + + const originalLength = creativeHtml.length; + const sanitizer = getCreativeSanitizer(); + + if (!sanitizer || !sanitizer.isSupported) { + return { + kind: 'rejected', + originalLength, + sanitizedLength: 0, + removedCount: 0, + rejectionReason: 'sanitizer-unavailable', + }; + } + + const sanitizedHtml = sanitizer.sanitize(creativeHtml, { + // Keep the result as a plain string because iframe.srcdoc expects string HTML. + RETURN_TRUSTED_TYPE: false, + }); + const removedItems = [...sanitizer.removed]; + const sanitizedLength = sanitizedHtml.length; + + if (removedItems.some(isDangerousRemoval) || hasDangerousMarkup(sanitizedHtml)) { + return { + kind: 'rejected', + originalLength, + sanitizedLength, + removedCount: removedItems.length, + rejectionReason: 'removed-dangerous-content', + }; + } + + if (sanitizedHtml.trim().length === 0) { + return { + kind: 'rejected', + originalLength, + sanitizedLength, + removedCount: removedItems.length, + rejectionReason: 'empty-after-sanitize', + }; + } + + return { + kind: 'accepted', + originalLength, + sanitizedHtml, + sanitizedLength, + removedCount: removedItems.length, + }; +} + // Locate an ad slot element by id, tolerating funky selectors provided by tag managers. export function findSlot(id: string): HTMLElement | null { const nid = normalizeId(id); @@ -85,7 +290,7 @@ export function renderAllAdUnits(): void { type IframeOptions = { name?: string; title?: string; width?: number; height?: number }; -// Construct a sandboxed iframe sized for the ad so we can render arbitrary HTML. +// Construct a sandboxed iframe sized for sanitized, non-executable creative HTML. export function createAdIframe( container: HTMLElement, opts: IframeOptions = {} @@ -101,16 +306,14 @@ export function createAdIframe( iframe.setAttribute('aria-label', 'Advertisement'); // Sandbox permissions for creatives try { - iframe.sandbox.add( - 'allow-forms', - 'allow-popups', - 'allow-popups-to-escape-sandbox', - 'allow-same-origin', - 'allow-scripts', - 'allow-top-navigation-by-user-activation' - ); + if (iframe.sandbox && typeof iframe.sandbox.add === 'function') { + iframe.sandbox.add(...CREATIVE_SANDBOX_TOKENS); + } else { + iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' ')); + } } catch (err) { log.debug('createAdIframe: sandbox add failed', err); + iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' ')); } // Sizing + style const w = Math.max(0, Number(opts.width ?? 0) | 0); @@ -129,10 +332,10 @@ export function createAdIframe( return iframe; } -// Build a complete HTML document for a creative, suitable for use with iframe.srcdoc +// Build a complete HTML document for a sanitized creative fragment, suitable for iframe.srcdoc. export function buildCreativeDocument(creativeHtml: string): string { - return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', NORMALIZE_CSS).replace( + return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS).replace( '%CREATIVE_HTML%', - creativeHtml + () => creativeHtml ); } diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index 1aa4f0d1..a91d32e6 100644 --- a/crates/js/lib/src/core/request.ts +++ b/crates/js/lib/src/core/request.ts @@ -2,7 +2,7 @@ import { log } from './log'; import { collectContext } from './context'; import { getAllUnits, firstSize } from './registry'; -import { createAdIframe, findSlot, buildCreativeDocument } from './render'; +import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render'; import { buildAdRequest, sendAuction } from './auction'; export type RequestAdsCallback = () => void; @@ -11,6 +11,16 @@ export interface RequestAdsOptions { timeout?: number; } +type RenderCreativeInlineOptions = { + slotId: string; + // Accept unknown input here because bidder JSON is untrusted at runtime. + creativeHtml: unknown; + creativeWidth?: number; + creativeHeight?: number; + seat: string; + creativeId: string; +}; + // Entry point matching Prebid's requestBids signature; uses unified /auction endpoint. export function requestAds( callbackOrOpts?: RequestAdsCallback | RequestAdsOptions, @@ -38,8 +48,15 @@ export function requestAds( .then((bids) => { log.info('requestAds: got bids', { count: bids.length }); for (const bid of bids) { - if (bid.impid && bid.adm) { - renderCreativeInline(bid.impid, bid.adm, bid.width, bid.height); + if (bid.impid) { + renderCreativeInline({ + slotId: bid.impid, + creativeHtml: bid.adm, + creativeWidth: bid.width, + creativeHeight: bid.height, + seat: bid.seat, + creativeId: bid.creativeId, + }); } } log.info('requestAds: rendered creatives from response'); @@ -59,23 +76,39 @@ export function requestAds( } } -// Render a creative by writing HTML directly into a sandboxed iframe. -function renderCreativeInline( - slotId: string, - creativeHtml: string, - creativeWidth?: number, - creativeHeight?: number -): void { +// Render a creative by writing sanitized, non-executable HTML into a sandboxed iframe. +function renderCreativeInline({ + slotId, + creativeHtml, + creativeWidth, + creativeHeight, + seat, + creativeId, +}: RenderCreativeInlineOptions): void { const container = findSlot(slotId) as HTMLElement | null; if (!container) { - log.warn('renderCreativeInline: slot not found; skipping render', { slotId }); + log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId }); return; } try { - // Clear previous content + // Clear the slot before render so rejected creatives fail closed with no stale markup left behind. container.innerHTML = ''; + const sanitization = sanitizeCreativeHtml(creativeHtml); + if (sanitization.kind === 'rejected') { + log.warn('renderCreativeInline: rejected creative', { + slotId, + seat, + creativeId, + originalLength: sanitization.originalLength, + sanitizedLength: sanitization.sanitizedLength, + removedCount: sanitization.removedCount, + rejectionReason: sanitization.rejectionReason, + }); + return; + } + // Determine size with fallback chain: creative size → ad unit size → 300x250 let width: number; let height: number; @@ -99,15 +132,19 @@ function renderCreativeInline( height, }); - iframe.srcdoc = buildCreativeDocument(creativeHtml); + iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); log.info('renderCreativeInline: rendered', { slotId, + seat, + creativeId, width, height, - htmlLength: creativeHtml.length, + originalLength: sanitization.originalLength, + sanitizedLength: sanitization.sanitizedLength, + removedCount: sanitization.removedCount, }); } catch (err) { - log.warn('renderCreativeInline: failed', { slotId, err }); + log.warn('renderCreativeInline: failed', { slotId, seat, creativeId, err }); } } diff --git a/crates/js/lib/test/core/render.test.ts b/crates/js/lib/test/core/render.test.ts index 38683de4..f9ba9106 100644 --- a/crates/js/lib/test/core/render.test.ts +++ b/crates/js/lib/test/core/render.test.ts @@ -6,17 +6,145 @@ describe('render', () => { document.body.innerHTML = ''; }); - it('creates a sandboxed iframe with creative HTML via srcdoc', async () => { - const { createAdIframe, buildCreativeDocument } = await import('../../src/core/render'); + it('creates a sandboxed iframe with sanitized creative HTML via srcdoc', async () => { + const { createAdIframe, buildCreativeDocument, sanitizeCreativeHtml } = + await import('../../src/core/render'); const div = document.createElement('div'); div.id = 'slotA'; document.body.appendChild(div); const iframe = createAdIframe(div, { name: 'test', width: 300, height: 250 }); - iframe.srcdoc = buildCreativeDocument('ad'); + const sanitization = sanitizeCreativeHtml('ad'); + + expect(sanitization.kind).toBe('accepted'); + if (sanitization.kind !== 'accepted') { + throw new Error('should accept safe creative markup'); + } + + iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml); expect(iframe).toBeTruthy(); expect(iframe.srcdoc).toContain('ad'); expect(div.querySelector('iframe')).toBe(iframe); + const sandbox = iframe.getAttribute('sandbox') ?? ''; + expect(sandbox).toContain('allow-forms'); + expect(sandbox).toContain('allow-popups'); + expect(sandbox).toContain('allow-popups-to-escape-sandbox'); + expect(sandbox).toContain('allow-top-navigation-by-user-activation'); + expect(sandbox).not.toContain('allow-same-origin'); + expect(sandbox).not.toContain('allow-scripts'); + }); + + it('preserves dollar sequences when building the creative document', async () => { + const { buildCreativeDocument } = await import('../../src/core/render'); + const creativeHtml = "
';
+ (globalThis as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ({
+ seatbid: [
+ {
+ seat: 'trusted-server',
+ bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-safe-uri' }],
+ },
+ ],
+ }),
+ });
+
+ const { addAdUnits } = await import('../../src/core/registry');
+ const { requestAds } = await import('../../src/core/request');
+
+ document.body.innerHTML = '';
+ addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any);
+
+ requestAds();
+ await flushRequestAds();
+
+ const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null;
+ expect(iframe).toBeTruthy();
+ expect(iframe!.srcdoc).toContain('mailto:test@example.com');
+ expect(iframe!.srcdoc).toContain('https://example.com/ad.png');
+ });
+
+ it('rejects creatives with stripped executable content without logging raw HTML', async () => {
+ const creativeHtml = '
';
+ (globalThis as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ({
+ seatbid: [
+ {
+ seat: 'appnexus',
+ bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-danger' }],
+ },
+ ],
+ }),
+ });
+
+ const { addAdUnits } = await import('../../src/core/registry');
+ const { log } = await import('../../src/core/log');
+ const { requestAds } = await import('../../src/core/request');
+ const warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined);
+
+ document.body.innerHTML = '