@@ -6,11 +6,88 @@ import { getUnit, getAllUnits, firstSize } from './registry';
66import NORMALIZE_CSS from './styles/normalize.css?inline' ;
77import 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+
949function 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.
1592export function findSlot ( id : string ) : HTMLElement | null {
1693 const nid = normalizeId ( id ) ;
@@ -85,7 +162,7 @@ export function renderAllAdUnits(): void {
85162
86163type 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.
89166export 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.
133208export 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}
0 commit comments