Skip to content

Commit 4a5d6a4

Browse files
authored
Harden inline creative rendering (#459)
* Harden inline creative rendering
1 parent c937995 commit 4a5d6a4

7 files changed

Lines changed: 1058 additions & 46 deletions

File tree

crates/common/src/auction/formats.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,17 @@ pub fn convert_to_openrtb_response(
230230
let width = to_openrtb_i32(bid.width, "width", &bid_context);
231231
let height = to_openrtb_i32(bid.height, "height", &bid_context);
232232

233-
// Process creative HTML if present - rewrite URLs and return inline
233+
// Process creative HTML if present - — sanitize dangerous markup first, then rewrite URLs.
234234
let creative_html = if let Some(ref raw_creative) = bid.creative {
235-
// Rewrite creative HTML with proxy URLs for first-party delivery
236-
let rewritten = creative::rewrite_creative_html(settings, raw_creative);
235+
let sanitized = creative::sanitize_creative_html(raw_creative);
236+
let rewritten = creative::rewrite_creative_html(settings, &sanitized);
237237

238238
log::debug!(
239-
"Rewritten creative for auction {} slot {} ({} bytes)",
239+
"Processed creative for auction {} slot {} ({} → {} → {} bytes)",
240240
auction_request.id,
241241
slot_id,
242+
raw_creative.len(),
243+
sanitized.len(),
242244
rewritten.len()
243245
);
244246

crates/common/src/creative.rs

Lines changed: 618 additions & 1 deletion
Large diffs are not rendered by default.

crates/js/lib/src/core/auction.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ export function parseAuctionResponse(body: any): AuctionBid[] {
114114
if (!Array.isArray(sbBids)) continue;
115115

116116
for (const b of sbBids) {
117+
// Coerce missing/null adm to '' so AuctionBid.adm is always a string.
118+
// The empty-string case is filtered in renderCreativeInline via the
119+
// `if (!bid.adm)` guard. The client-side `typeof !== 'string'` check in
120+
// sanitizeCreativeHtml is a second line of defense for callers that bypass
121+
// parseAuctionResponse and pass untrusted values directly.
117122
bids.push({
118123
impid: b.impid ?? '',
119124
adm: b.adm ?? '',

crates/js/lib/src/core/render.ts

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,88 @@ import { getUnit, getAllUnits, firstSize } from './registry';
66
import NORMALIZE_CSS from './styles/normalize.css?inline';
77
import IFRAME_TEMPLATE from './templates/iframe.html?raw';
88

9+
// Sandbox permissions granted to creative iframes.
10+
// Notably absent:
11+
// allow-scripts, allow-same-origin — prevent JS execution and same-origin
12+
// access, which are the primary attack vectors for malicious creatives.
13+
// allow-forms — server-side sanitization strips <form> elements, so form
14+
// submission from creatives is not a supported use case. Omitting this token
15+
// is consistent with that server-side policy and reduces the attack surface.
16+
const CREATIVE_SANDBOX_TOKENS = [
17+
'allow-popups',
18+
'allow-popups-to-escape-sandbox',
19+
'allow-top-navigation-by-user-activation',
20+
] as const;
21+
22+
export type CreativeSanitizationRejectionReason = 'empty-after-sanitize' | 'invalid-creative-html';
23+
24+
export type AcceptedCreativeHtml = {
25+
kind: 'accepted';
26+
originalLength: number;
27+
sanitizedHtml: string;
28+
// Always equal to originalLength: the client validates type/emptiness only;
29+
// server-side sanitization has already run before adm reaches this function.
30+
// Retained so both union members of SanitizeCreativeHtmlResult have consistent fields.
31+
sanitizedLength: number;
32+
// Always 0 for the same reason — no content is removed client-side.
33+
removedCount: number;
34+
};
35+
36+
export type RejectedCreativeHtml = {
37+
kind: 'rejected';
38+
originalLength: number;
39+
// Always equal to originalLength (or 0 for non-string input): no client-side
40+
// removal occurs. Retained so both union members of SanitizeCreativeHtmlResult have consistent fields.
41+
sanitizedLength: number;
42+
// Always 0 — no content is removed client-side.
43+
removedCount: number;
44+
rejectionReason: CreativeSanitizationRejectionReason;
45+
};
46+
47+
export type SanitizeCreativeHtmlResult = AcceptedCreativeHtml | RejectedCreativeHtml;
48+
949
function normalizeId(raw: string): string {
1050
const s = String(raw ?? '').trim();
1151
return s.startsWith('#') ? s.slice(1) : s;
1252
}
1353

54+
// Validate the untrusted creative fragment before embedding it in the sandboxed iframe.
55+
// Dangerous markup is stripped server-side before adm reaches the client; this function
56+
// only guards against type errors and empty payloads. As a result, sanitizedLength always
57+
// equals originalLength and removedCount is always 0 for accepted creatives — these fields
58+
// exist for structural consistency with the shared result type but carry no signal here.
59+
export function sanitizeCreativeHtml(creativeHtml: unknown): SanitizeCreativeHtmlResult {
60+
if (typeof creativeHtml !== 'string') {
61+
return {
62+
kind: 'rejected',
63+
originalLength: 0,
64+
sanitizedLength: 0,
65+
removedCount: 0,
66+
rejectionReason: 'invalid-creative-html',
67+
};
68+
}
69+
70+
const originalLength = creativeHtml.length;
71+
72+
if (creativeHtml.trim().length === 0) {
73+
return {
74+
kind: 'rejected',
75+
originalLength,
76+
sanitizedLength: originalLength,
77+
removedCount: 0,
78+
rejectionReason: 'empty-after-sanitize',
79+
};
80+
}
81+
82+
return {
83+
kind: 'accepted',
84+
originalLength,
85+
sanitizedHtml: creativeHtml,
86+
sanitizedLength: originalLength,
87+
removedCount: 0,
88+
};
89+
}
90+
1491
// Locate an ad slot element by id, tolerating funky selectors provided by tag managers.
1592
export function findSlot(id: string): HTMLElement | null {
1693
const nid = normalizeId(id);
@@ -85,7 +162,7 @@ export function renderAllAdUnits(): void {
85162

86163
type IframeOptions = { name?: string; title?: string; width?: number; height?: number };
87164

88-
// Construct a sandboxed iframe sized for the ad so we can render arbitrary HTML.
165+
// Construct a sandboxed iframe sized for sanitized, non-executable creative HTML.
89166
export function createAdIframe(
90167
container: HTMLElement,
91168
opts: IframeOptions = {}
@@ -101,16 +178,14 @@ export function createAdIframe(
101178
iframe.setAttribute('aria-label', 'Advertisement');
102179
// Sandbox permissions for creatives
103180
try {
104-
iframe.sandbox.add(
105-
'allow-forms',
106-
'allow-popups',
107-
'allow-popups-to-escape-sandbox',
108-
'allow-same-origin',
109-
'allow-scripts',
110-
'allow-top-navigation-by-user-activation'
111-
);
181+
if (iframe.sandbox && typeof iframe.sandbox.add === 'function') {
182+
iframe.sandbox.add(...CREATIVE_SANDBOX_TOKENS);
183+
} else {
184+
iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' '));
185+
}
112186
} catch (err) {
113187
log.debug('createAdIframe: sandbox add failed', err);
188+
iframe.setAttribute('sandbox', CREATIVE_SANDBOX_TOKENS.join(' '));
114189
}
115190
// Sizing + style
116191
const w = Math.max(0, Number(opts.width ?? 0) | 0);
@@ -129,10 +204,10 @@ export function createAdIframe(
129204
return iframe;
130205
}
131206

132-
// Build a complete HTML document for a creative, suitable for use with iframe.srcdoc
207+
// Build a complete HTML document for a sanitized creative fragment, suitable for iframe.srcdoc.
133208
export function buildCreativeDocument(creativeHtml: string): string {
134-
return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', NORMALIZE_CSS).replace(
209+
return IFRAME_TEMPLATE.replace('%NORMALIZE_CSS%', () => NORMALIZE_CSS).replace(
135210
'%CREATIVE_HTML%',
136-
creativeHtml
211+
() => creativeHtml
137212
);
138213
}

crates/js/lib/src/core/request.ts

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { log } from './log';
33
import { collectContext } from './context';
44
import { getAllUnits, firstSize } from './registry';
5-
import { createAdIframe, findSlot, buildCreativeDocument } from './render';
5+
import { createAdIframe, findSlot, buildCreativeDocument, sanitizeCreativeHtml } from './render';
66
import { buildAdRequest, sendAuction } from './auction';
77

88
export type RequestAdsCallback = () => void;
@@ -11,6 +11,16 @@ export interface RequestAdsOptions {
1111
timeout?: number;
1212
}
1313

14+
type RenderCreativeInlineOptions = {
15+
slotId: string;
16+
// Accept unknown input here because bidder JSON is untrusted at runtime.
17+
creativeHtml: unknown;
18+
creativeWidth?: number;
19+
creativeHeight?: number;
20+
seat: string;
21+
creativeId: string;
22+
};
23+
1424
// Entry point matching Prebid's requestBids signature; uses unified /auction endpoint.
1525
export function requestAds(
1626
callbackOrOpts?: RequestAdsCallback | RequestAdsOptions,
@@ -38,9 +48,19 @@ export function requestAds(
3848
.then((bids) => {
3949
log.info('requestAds: got bids', { count: bids.length });
4050
for (const bid of bids) {
41-
if (bid.impid && bid.adm) {
42-
renderCreativeInline(bid.impid, bid.adm, bid.width, bid.height);
51+
if (!bid.impid) continue;
52+
if (!bid.adm) {
53+
log.debug('requestAds: bid has no adm, skipping', { slotId: bid.impid });
54+
continue;
4355
}
56+
renderCreativeInline({
57+
slotId: bid.impid,
58+
creativeHtml: bid.adm,
59+
creativeWidth: bid.width,
60+
creativeHeight: bid.height,
61+
seat: bid.seat,
62+
creativeId: bid.creativeId,
63+
});
4464
}
4565
log.info('requestAds: rendered creatives from response');
4666
})
@@ -59,21 +79,35 @@ export function requestAds(
5979
}
6080
}
6181

62-
// Render a creative by writing HTML directly into a sandboxed iframe.
63-
function renderCreativeInline(
64-
slotId: string,
65-
creativeHtml: string,
66-
creativeWidth?: number,
67-
creativeHeight?: number
68-
): void {
82+
// Render a creative by writing sanitized, non-executable HTML into a sandboxed iframe.
83+
function renderCreativeInline({
84+
slotId,
85+
creativeHtml,
86+
creativeWidth,
87+
creativeHeight,
88+
seat,
89+
creativeId,
90+
}: RenderCreativeInlineOptions): void {
6991
const container = findSlot(slotId) as HTMLElement | null;
7092
if (!container) {
71-
log.warn('renderCreativeInline: slot not found; skipping render', { slotId });
93+
log.warn('renderCreativeInline: slot not found; skipping render', { slotId, seat, creativeId });
7294
return;
7395
}
7496

7597
try {
76-
// Clear previous content
98+
const sanitization = sanitizeCreativeHtml(creativeHtml);
99+
if (sanitization.kind === 'rejected') {
100+
log.warn('renderCreativeInline: rejected creative', {
101+
slotId,
102+
seat,
103+
creativeId,
104+
originalLength: sanitization.originalLength,
105+
rejectionReason: sanitization.rejectionReason,
106+
});
107+
return;
108+
}
109+
110+
// Clear the slot only after sanitization succeeds so rejected creatives never blank existing content.
77111
container.innerHTML = '';
78112

79113
// Determine size with fallback chain: creative size → ad unit size → 300x250
@@ -99,15 +133,17 @@ function renderCreativeInline(
99133
height,
100134
});
101135

102-
iframe.srcdoc = buildCreativeDocument(creativeHtml);
136+
iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml);
103137

104138
log.info('renderCreativeInline: rendered', {
105139
slotId,
140+
seat,
141+
creativeId,
106142
width,
107143
height,
108-
htmlLength: creativeHtml.length,
144+
originalLength: sanitization.originalLength,
109145
});
110146
} catch (err) {
111-
log.warn('renderCreativeInline: failed', { slotId, err });
147+
log.warn('renderCreativeInline: failed', { slotId, seat, creativeId, err });
112148
}
113149
}

crates/js/lib/test/core/render.test.ts

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,104 @@ describe('render', () => {
66
document.body.innerHTML = '';
77
});
88

9-
it('creates a sandboxed iframe with creative HTML via srcdoc', async () => {
10-
const { createAdIframe, buildCreativeDocument } = await import('../../src/core/render');
9+
it('creates a sandboxed iframe with sanitized creative HTML via srcdoc', async () => {
10+
const { createAdIframe, buildCreativeDocument, sanitizeCreativeHtml } =
11+
await import('../../src/core/render');
1112
const div = document.createElement('div');
1213
div.id = 'slotA';
1314
document.body.appendChild(div);
1415

1516
const iframe = createAdIframe(div, { name: 'test', width: 300, height: 250 });
16-
iframe.srcdoc = buildCreativeDocument('<span>ad</span>');
17+
const sanitization = sanitizeCreativeHtml('<span>ad</span>');
18+
19+
expect(sanitization.kind).toBe('accepted');
20+
if (sanitization.kind !== 'accepted') {
21+
throw new Error('should accept safe creative markup');
22+
}
23+
24+
iframe.srcdoc = buildCreativeDocument(sanitization.sanitizedHtml);
1725

1826
expect(iframe).toBeTruthy();
1927
expect(iframe.srcdoc).toContain('<span>ad</span>');
2028
expect(div.querySelector('iframe')).toBe(iframe);
29+
const sandbox = iframe.getAttribute('sandbox') ?? '';
30+
expect(sandbox).not.toContain('allow-forms');
31+
expect(sandbox).toContain('allow-popups');
32+
expect(sandbox).toContain('allow-popups-to-escape-sandbox');
33+
expect(sandbox).toContain('allow-top-navigation-by-user-activation');
34+
expect(sandbox).not.toContain('allow-same-origin');
35+
expect(sandbox).not.toContain('allow-scripts');
36+
});
37+
38+
it('preserves dollar sequences when building the creative document', async () => {
39+
const { buildCreativeDocument } = await import('../../src/core/render');
40+
const creativeHtml = "<div>$& $$ $1 $` $'</div>";
41+
const documentHtml = buildCreativeDocument(creativeHtml);
42+
43+
expect(documentHtml).toContain(creativeHtml);
44+
});
45+
46+
it('accepts safe static markup during sanitization', async () => {
47+
const { sanitizeCreativeHtml } = await import('../../src/core/render');
48+
const sanitization = sanitizeCreativeHtml(
49+
'<div><a href="mailto:test@example.com">Contact</a><img src="https://example.com/ad.png" alt="ad creative"></div>'
50+
);
51+
52+
expect(sanitization.kind).toBe('accepted');
53+
if (sanitization.kind !== 'accepted') {
54+
throw new Error('should accept safe static creative HTML');
55+
}
56+
57+
expect(sanitization.sanitizedHtml).toContain('<img');
58+
expect(sanitization.sanitizedHtml).toContain('mailto:test@example.com');
59+
expect(sanitization.removedCount).toBe(0);
60+
});
61+
62+
it('accepts safe inline styles during sanitization', async () => {
63+
const { sanitizeCreativeHtml } = await import('../../src/core/render');
64+
const sanitization = sanitizeCreativeHtml('<div style="color: red">styled creative</div>');
65+
66+
expect(sanitization.kind).toBe('accepted');
67+
if (sanitization.kind !== 'accepted') {
68+
throw new Error('should accept safe inline styles');
69+
}
70+
71+
expect(sanitization.sanitizedHtml).toContain('style=');
72+
expect(sanitization.removedCount).toBe(0);
73+
});
74+
75+
it('accepts server-sanitized creative HTML (content-based checks are server-side)', async () => {
76+
const { sanitizeCreativeHtml } = await import('../../src/core/render');
77+
// The server strips dangerous markup before adm reaches the client.
78+
// The client only validates type and emptiness — content passes through.
79+
const sanitization = sanitizeCreativeHtml(
80+
'<div><img src="https://cdn.example.com/ad.png" alt="ad"></div>'
81+
);
82+
83+
expect(sanitization.kind).toBe('accepted');
84+
});
85+
86+
it('rejects malformed non-string creative HTML', async () => {
87+
const { sanitizeCreativeHtml } = await import('../../src/core/render');
88+
const sanitization = sanitizeCreativeHtml({ html: '<div>bad</div>' });
89+
90+
expect(sanitization).toEqual(
91+
expect.objectContaining({
92+
kind: 'rejected',
93+
rejectionReason: 'invalid-creative-html',
94+
})
95+
);
96+
});
97+
98+
it('rejects creatives that sanitize to empty markup', async () => {
99+
const { sanitizeCreativeHtml } = await import('../../src/core/render');
100+
const sanitization = sanitizeCreativeHtml(' ');
101+
102+
expect(sanitization).toEqual(
103+
expect.objectContaining({
104+
kind: 'rejected',
105+
rejectionReason: 'empty-after-sanitize',
106+
})
107+
);
21108
});
22109
});

0 commit comments

Comments
 (0)