diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs
index 3eb4d843..71347027 100644
--- a/crates/common/src/auction/formats.rs
+++ b/crates/common/src/auction/formats.rs
@@ -217,15 +217,17 @@ pub fn convert_to_openrtb_response(
})
})?;
- // Process creative HTML if present - rewrite URLs and return inline
+ // Process creative HTML if present — sanitize dangerous markup first, then rewrite URLs.
let creative_html = if let Some(ref raw_creative) = bid.creative {
- // Rewrite creative HTML with proxy URLs for first-party delivery
- let rewritten = creative::rewrite_creative_html(settings, raw_creative);
+ let sanitized = creative::sanitize_creative_html(raw_creative);
+ let rewritten = creative::rewrite_creative_html(settings, &sanitized);
log::debug!(
- "Rewritten creative for auction {} slot {} ({} bytes)",
+ "Processed creative for auction {} slot {} ({} → {} → {} bytes)",
auction_request.id,
slot_id,
+ raw_creative.len(),
+ sanitized.len(),
rewritten.len()
);
diff --git a/crates/common/src/creative.rs b/crates/common/src/creative.rs
index 63db7e6f..aa0f55a2 100644
--- a/crates/common/src/creative.rs
+++ b/crates/common/src/creative.rs
@@ -303,6 +303,170 @@ pub fn rewrite_css_body(settings: &Settings, css: &str) -> String {
rewrite_style_urls(settings, css)
}
+/// Maximum byte length of creative HTML accepted by [`sanitize_creative_html`].
+///
+/// Inputs larger than this are returned unchanged with a warning to avoid unbounded
+/// allocations on the hot path. Fastly Compute enforces upstream request-body limits,
+/// but this guard protects internal callers too.
+const MAX_CREATIVE_SIZE: usize = 1024 * 1024; // 1 MiB
+
+/// Returns `true` if a lowercased `data:` URI points to a safe, non-executable MIME type.
+///
+/// Only well-known raster image formats are allowed. `data:image/svg+xml` is **excluded**
+/// because SVG documents can contain `"#;
+ let out = sanitize_creative_html(html);
+ assert!(!out.contains("">"#;
+ let out = sanitize_creative_html(html);
+ assert!(!out.contains("data:text/html"), "should strip data: src");
+ }
+
+ #[test]
+ fn sanitize_strips_dangerous_inline_style() {
+ let html = r#"
ad
"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("expression("),
+ "should strip expression() in style"
+ );
+ assert!(out.contains("ad"), "should preserve element content");
+ }
+
+ #[test]
+ fn sanitize_strips_javascript_in_style() {
+ let html = r#"ad
"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("javascript:"),
+ "should strip javascript: in style"
+ );
+ }
+
+ #[test]
+ fn sanitize_preserves_safe_inline_style() {
+ let html = r#"styled ad
"#;
+ let out = sanitize_creative_html(html);
+ assert!(out.contains("style="), "should preserve safe inline style");
+ assert!(out.contains("color:red"), "should preserve style value");
+ }
+
+ #[test]
+ fn sanitize_preserves_mailto_href() {
+ let html = r#"email"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ out.contains("mailto:contact@example.com"),
+ "should preserve mailto href"
+ );
+ }
+
+ #[test]
+ fn sanitize_passes_through_empty_input() {
+ let out = sanitize_creative_html("");
+ assert_eq!(out, "", "should return empty string unchanged");
+ }
+
+ #[test]
+ fn sanitize_removes_link_element() {
+ let html = r#""#;
+ let out = sanitize_creative_html(html);
+ assert!(!out.contains("ad"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.to_ascii_lowercase().contains("onclick"),
+ "should strip ONCLICK"
+ );
+ assert!(
+ !out.to_ascii_lowercase().contains("onmouseover"),
+ "should strip OnMouseOver"
+ );
+ assert!(out.contains("ad"), "should preserve element content");
+ }
+
+ #[test]
+ fn sanitize_strips_javascript_in_action_and_formaction() {
+ let html = r#""#;
+ let out = sanitize_creative_html(html);
+ // form is fully removed; button survives but formaction is stripped
+ assert!(
+ !out.contains("javascript:"),
+ "should strip javascript: URIs"
+ );
+ }
+
+ #[test]
+ fn sanitize_strips_javascript_in_background_and_poster() {
+ let html = r#""#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("javascript:"),
+ "should strip javascript: in background and poster"
+ );
+ }
+
+ #[test]
+ fn sanitize_strips_javascript_in_xlink_href() {
+ let html = r#""#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("javascript:"),
+ "should strip javascript: in xlink:href"
+ );
+ }
+
+ #[test]
+ fn sanitize_strips_whitespace_padded_dangerous_uri() {
+ // Dangerous URIs may have leading whitespace before the scheme.
+ let html = r#"click"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("javascript:"),
+ "should strip whitespace-padded javascript: href"
+ );
+ }
+
+ #[test]
+ fn sanitize_preserves_data_image_src() {
+ // Safe raster formats must pass through unchanged.
+ for mime in &[
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "image/avif",
+ ] {
+ let html = format!(r#"
"#);
+ let out = sanitize_creative_html(&html);
+ assert!(
+ out.contains(&format!("data:{mime};base64,")),
+ "should preserve data:{mime} src"
+ );
+ }
+ }
+
+ #[test]
+ fn sanitize_strips_data_svg_src() {
+ // data:image/svg+xml can embed ')">ad"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("data:text/"),
+ "should strip data:text/ in style url()"
+ );
+ }
+
+ #[test]
+ fn sanitize_strips_data_svg_in_style_url() {
+ // data:image/svg+xml inside a CSS url() can execute JS — must be stripped.
+ let html =
+ r#"ad
"#;
+ let out = sanitize_creative_html(html);
+ assert!(
+ !out.contains("data:image/svg"),
+ "should strip data:image/svg in style url()"
+ );
+ }
+
+ #[test]
+ fn sanitize_returns_unchanged_when_over_size_limit() {
+ // Inputs exceeding MAX_CREATIVE_SIZE must be returned as-is without processing.
+ let large = "A".repeat(super::MAX_CREATIVE_SIZE + 1);
+ let out = sanitize_creative_html(&large);
+ assert_eq!(out, large, "should return oversized input unchanged");
+ }
}
diff --git a/crates/js/lib/src/core/render.ts b/crates/js/lib/src/core/render.ts
index 42a277b9..e85ec968 100644
--- a/crates/js/lib/src/core/render.ts
+++ b/crates/js/lib/src/core/render.ts
@@ -6,11 +6,72 @@ import { getUnit, getAllUnits, firstSize } from './registry';
import NORMALIZE_CSS from './styles/normalize.css?inline';
import IFRAME_TEMPLATE from './templates/iframe.html?raw';
+const CREATIVE_SANDBOX_TOKENS = [
+ 'allow-popups',
+ 'allow-popups-to-escape-sandbox',
+ 'allow-top-navigation-by-user-activation',
+] as const;
+
+export type CreativeSanitizationRejectionReason = 'empty-after-sanitize' | 'invalid-creative-html';
+
+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;
+
function normalizeId(raw: string): string {
const s = String(raw ?? '').trim();
return s.startsWith('#') ? s.slice(1) : s;
}
+// Validate the untrusted creative fragment before embedding it in the sandboxed iframe.
+// Dangerous markup is stripped server-side before adm reaches the client; this function
+// only guards against type errors and empty payloads.
+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;
+
+ if (creativeHtml.trim().length === 0) {
+ return {
+ kind: 'rejected',
+ originalLength,
+ sanitizedLength: originalLength,
+ removedCount: 0,
+ rejectionReason: 'empty-after-sanitize',
+ };
+ }
+
+ return {
+ kind: 'accepted',
+ originalLength,
+ sanitizedHtml: creativeHtml,
+ sanitizedLength: originalLength,
+ removedCount: 0,
+ };
+}
+
// 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 +146,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 +162,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 +188,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..28704f06 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,9 +48,19 @@ 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) continue;
+ if (!bid.adm) {
+ log.debug('requestAds: bid has no adm, skipping', { slotId: bid.impid });
+ continue;
}
+ 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,21 +79,37 @@ 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
+ 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;
+ }
+
+ // Clear the slot only after sanitization succeeds so rejected creatives never blank existing content.
container.innerHTML = '';
// Determine size with fallback chain: creative size → ad unit size → 300x250
@@ -99,15 +135,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..5bdb3a81 100644
--- a/crates/js/lib/test/core/render.test.ts
+++ b/crates/js/lib/test/core/render.test.ts
@@ -6,17 +6,104 @@ 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).not.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 = "$& $$ $1 $` $'
";
+ const documentHtml = buildCreativeDocument(creativeHtml);
+
+ expect(documentHtml).toContain(creativeHtml);
+ });
+
+ it('accepts safe static markup during sanitization', async () => {
+ const { sanitizeCreativeHtml } = await import('../../src/core/render');
+ const sanitization = sanitizeCreativeHtml(
+ ''
+ );
+
+ 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('accepts server-sanitized creative HTML (content-based checks are server-side)', async () => {
+ const { sanitizeCreativeHtml } = await import('../../src/core/render');
+ // The server strips dangerous markup before adm reaches the client.
+ // The client only validates type and emptiness — content passes through.
+ const sanitization = sanitizeCreativeHtml(
+ ''
+ );
+
+ expect(sanitization.kind).toBe('accepted');
+ });
+
+ 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..0518f938 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,168 @@ 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 =
+ 'Contact
';
+ (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 malformed non-string creative HTML without blanking the slot', async () => {
+ (globalThis as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ({
+ seatbid: [
+ {
+ seat: 'appnexus',
+ bid: [{ impid: 'slot1', adm: { html: 'bad
' }, crid: 'creative-invalid' }],
+ },
+ ],
+ }),
+ });
+
+ 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();
+ // Invalid-type rejection must not blank existing slot content.
+ expect(document.querySelector('#slot1')?.innerHTML).toBe('existing');
+
+ const rejectionCall = warnSpy.mock.calls.find(
+ ([message]) => message === 'renderCreativeInline: rejected creative'
+ );
+ expect(rejectionCall?.[1]).toEqual(
+ expect.objectContaining({
+ slotId: 'slot1',
+ seat: 'appnexus',
+ creativeId: 'creative-invalid',
+ rejectionReason: 'invalid-creative-html',
+ })
+ );
+ expect(JSON.stringify(rejectionCall)).not.toContain('[object Object]');
+ });
+
+ it('does not blank the slot when a later bid for the same slot is rejected', async () => {
+ // Regression: multi-bid scenario where a rejected bid must not erase an earlier
+ // successful render into the same slot.
+ const goodCreative = 'Safe Ad
';
+ (globalThis as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ({
+ seatbid: [
+ {
+ seat: 'seat-a',
+ bid: [{ impid: 'slot1', adm: goodCreative, crid: 'creative-good' }],
+ },
+ {
+ // Non-string adm is rejected client-side as invalid-creative-html.
+ seat: 'seat-b',
+ bid: [{ impid: 'slot1', adm: { html: 'bad
' }, crid: 'creative-bad' }],
+ },
+ ],
+ }),
+ });
+
+ 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();
+
+ // The good creative should have rendered; the bad one should not have blanked it.
+ const iframe = document.querySelector('#slot1 iframe') as HTMLIFrameElement | null;
+ expect(iframe).toBeTruthy();
+ expect(iframe!.srcdoc).toContain(goodCreative);
+ });
+
+ it('rejects creatives that sanitize to empty markup', async () => {
+ (globalThis as any).fetch = vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ headers: { get: () => 'application/json' },
+ json: async () => ({
+ seatbid: [
+ {
+ seat: 'appnexus',
+ bid: [{ impid: 'slot1', adm: ' ', crid: 'creative-empty' }],
+ },
+ ],
+ }),
+ });
+
+ 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 = '';
+ addAdUnits({ code: 'slot1', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any);
+
+ requestAds();
+ await flushRequestAds();
+
+ expect(document.querySelector('#slot1 iframe')).toBeNull();
+
+ const rejectionCall = warnSpy.mock.calls.find(
+ ([message]) => message === 'renderCreativeInline: rejected creative'
+ );
+ expect(rejectionCall?.[1]).toEqual(
+ expect.objectContaining({
+ slotId: 'slot1',
+ seat: 'appnexus',
+ creativeId: 'creative-empty',
+ rejectionReason: 'empty-after-sanitize',
+ })
+ );
});
it('skips iframe insertion when slot is missing', async () => {
@@ -127,7 +318,7 @@ describe('request.requestAds', () => {
addAdUnits({ code: 'missing-slot', mediaTypes: { banner: { sizes: [[300, 250]] } } } as any);
requestAds();
- await new Promise((r) => setTimeout(r, 0));
+ await flushRequestAds();
// No iframe should be inserted because the slot isn't present in DOM
const iframe = document.querySelector('iframe');