From 805c1edf9d7453e5e6e760c27aa55526a0a16243 Mon Sep 17 00:00:00 2001 From: prk-Jr Date: Sat, 7 Mar 2026 21:14:13 +0530 Subject: [PATCH 1/2] Harden inline creative rendering --- crates/js/lib/package-lock.json | 20 ++ crates/js/lib/package.json | 1 + crates/js/lib/src/core/render.ts | 228 +++++++++++++++++- crates/js/lib/src/core/request.ts | 64 +++-- crates/js/lib/test/core/render.test.ts | 126 +++++++++- crates/js/lib/test/core/request.test.ts | 308 +++++++++++++++++++++++- 6 files changed, 709 insertions(+), 38 deletions(-) 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..29db7e24 100644 --- a/crates/js/lib/src/core/render.ts +++ b/crates/js/lib/src/core/render.ts @@ -1,16 +1,226 @@ // 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 +): removedItem is RemovedAttribute | RemovedElement { + 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'); + 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 originalHadDangerousMarkup = hasDangerousMarkup(creativeHtml); + const sanitizedHtml = sanitizer.sanitize(creativeHtml, { + RETURN_TRUSTED_TYPE: false, + }); + const removedItems = [...sanitizer.removed]; + const sanitizedLength = sanitizedHtml.length; + + if ( + originalHadDangerousMarkup || + 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 +295,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 +311,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,7 +337,7 @@ 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( '%CREATIVE_HTML%', diff --git a/crates/js/lib/src/core/request.ts b/crates/js/lib/src/core/request.ts index 1aa4f0d1..f89943f1 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,15 @@ export interface RequestAdsOptions { timeout?: number; } +type RenderCreativeInlineOptions = { + slotId: string; + 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 +47,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,16 +75,18 @@ 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; } @@ -76,6 +94,20 @@ function renderCreativeInline( // Clear previous content 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 +131,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..901ac03d 100644 --- a/crates/js/lib/test/core/render.test.ts +++ b/crates/js/lib/test/core/render.test.ts @@ -6,17 +6,137 @@ 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('accepts safe static markup during sanitization', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml( + '
Contactad creative
' + ); + + expect(sanitization.kind).toBe('accepted'); + if (sanitization.kind !== 'accepted') { + throw new Error('should accept safe static creative HTML'); + } + + expect(sanitization.sanitizedHtml).toContain(' { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml('
styled creative
'); + + expect(sanitization.kind).toBe('accepted'); + if (sanitization.kind !== 'accepted') { + throw new Error('should accept safe inline styles'); + } + + expect(sanitization.sanitizedHtml).toContain('style='); + expect(sanitization.removedCount).toBe(0); + }); + + it('rejects creatives when executable content is stripped', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml('
danger
'); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'removed-dangerous-content', + }) + ); + }); + + it('rejects creatives with dangerous URI attributes', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml('danger'); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'removed-dangerous-content', + }) + ); + }); + + it('rejects creatives with dangerous data HTML image sources', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml( + 'danger' + ); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'removed-dangerous-content', + }) + ); + }); + + it('rejects creatives with dangerous inline styles that survive sanitization', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml( + '
danger
' + ); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'removed-dangerous-content', + }) + ); + }); + + it('rejects malformed non-string creative HTML', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml({ html: '
bad
' }); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'invalid-creative-html', + }) + ); + }); + + it('rejects creatives that sanitize to empty markup', async () => { + const { sanitizeCreativeHtml } = await import('../../src/core/render'); + const sanitization = sanitizeCreativeHtml(' '); + + expect(sanitization).toEqual( + expect.objectContaining({ + kind: 'rejected', + rejectionReason: 'empty-after-sanitize', + }) + ); }); }); diff --git a/crates/js/lib/test/core/request.test.ts b/crates/js/lib/test/core/request.test.ts index 635c2c79..7699bf0a 100644 --- a/crates/js/lib/test/core/request.test.ts +++ b/crates/js/lib/test/core/request.test.ts @@ -1,9 +1,21 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +async function flushRequestAds(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} describe('request.requestAds', () => { + let originalFetch: typeof globalThis.fetch; + beforeEach(async () => { await vi.resetModules(); document.body.innerHTML = ''; + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); }); it('sends fetch and renders creatives via iframe from response', async () => { @@ -14,19 +26,25 @@ describe('request.requestAds', () => { status: 200, headers: { get: () => 'application/json' }, json: async () => ({ - seatbid: [{ bid: [{ impid: 'slot1', adm: creativeHtml }] }], + seatbid: [ + { + seat: 'trusted-server', + bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-1' }], + }, + ], }), }); const { addAdUnits } = await import('../../src/core/registry'); + const { log } = await import('../../src/core/log'); const { requestAds } = await import('../../src/core/request'); + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => undefined); document.body.innerHTML = '
'; addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); - // Flush microtasks — sendAuction has fetch → .json() → parse chain - await new Promise((r) => setTimeout(r, 0)); + await flushRequestAds(); expect((globalThis as any).fetch).toHaveBeenCalled(); @@ -34,6 +52,19 @@ describe('request.requestAds', () => { const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; expect(iframe).toBeTruthy(); expect(iframe!.srcdoc).toContain(creativeHtml); + + const renderCall = infoSpy.mock.calls.find( + ([message]) => message === 'renderCreativeInline: rendered' + ); + expect(renderCall?.[1]).toEqual( + expect.objectContaining({ + slotId: 'slot1', + seat: 'trusted-server', + creativeId: 'creative-1', + originalLength: creativeHtml.length, + sanitizedLength: creativeHtml.length, + }) + ); }); it('does not render on non-JSON response', async () => { @@ -51,7 +82,7 @@ describe('request.requestAds', () => { addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); - await new Promise((r) => setTimeout(r, 0)); + await flushRequestAds(); expect((globalThis as any).fetch).toHaveBeenCalled(); expect(document.querySelector('iframe')).toBeNull(); @@ -67,7 +98,7 @@ describe('request.requestAds', () => { addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); - await new Promise((r) => setTimeout(r, 0)); + await flushRequestAds(); expect((globalThis as any).fetch).toHaveBeenCalled(); expect(document.querySelector('iframe')).toBeNull(); @@ -81,7 +112,12 @@ describe('request.requestAds', () => { status: 200, headers: { get: () => 'application/json' }, json: async () => ({ - seatbid: [{ bid: [{ impid: 'slot1', adm: creativeHtml }] }], + seatbid: [ + { + seat: 'trusted-server', + bid: [{ impid: 'slot1', adm: creativeHtml, crid: 'creative-2' }], + }, + ], }), }); @@ -97,13 +133,263 @@ describe('request.requestAds', () => { addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); requestAds(); - // Flush microtasks — sendAuction has fetch → .json() → parse chain - await new Promise((r) => setTimeout(r, 0)); + await flushRequestAds(); // Verify iframe was inserted with creative HTML in srcdoc const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null; expect(iframe).toBeTruthy(); - expect(iframe!.srcdoc).toContain(creativeHtml); + expect(iframe!.srcdoc).toContain(''); + expect(iframe!.srcdoc).toContain('Ad'); + }); + + it('renders creatives with safe URI markup', async () => { + const creativeHtml = + 'Contactad'; + (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 = '
existing
'; + addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any); + + requestAds(); + await flushRequestAds(); + + expect(document.querySelector('#slot1 iframe')).toBeNull(); + expect(document.querySelector('#slot1')?.innerHTML).toBe(''); + + const rejectionCall = warnSpy.mock.calls.find( + ([message]) => message === 'renderCreativeInline: rejected creative' + ); + expect(rejectionCall?.[1]).toEqual( + expect.objectContaining({ + slotId: 'slot1', + seat: 'appnexus', + creativeId: 'creative-danger', + rejectionReason: 'removed-dangerous-content', + }) + ); + expect(JSON.stringify(rejectionCall)).not.toContain('