-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathrender.ts
More file actions
213 lines (196 loc) · 7.72 KB
/
render.ts
File metadata and controls
213 lines (196 loc) · 7.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// Rendering utilities for Trusted Server demo placements: find slots, seed placeholders,
// and inject creatives into sandboxed iframes.
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';
// Sandbox permissions granted to creative iframes.
// Notably absent:
// allow-scripts, allow-same-origin — prevent JS execution and same-origin
// access, which are the primary attack vectors for malicious creatives.
// allow-forms — server-side sanitization strips <form> elements, so form
// submission from creatives is not a supported use case. Omitting this token
// is consistent with that server-side policy and reduces the attack surface.
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;
// Always equal to originalLength: the client validates type/emptiness only;
// server-side sanitization has already run before adm reaches this function.
// Retained so both union members of SanitizeCreativeHtmlResult have consistent fields.
sanitizedLength: number;
// Always 0 for the same reason — no content is removed client-side.
removedCount: number;
};
export type RejectedCreativeHtml = {
kind: 'rejected';
originalLength: number;
// Always equal to originalLength (or 0 for non-string input): no client-side
// removal occurs. Retained so both union members of SanitizeCreativeHtmlResult have consistent fields.
sanitizedLength: number;
// Always 0 — no content is removed client-side.
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. As a result, sanitizedLength always
// equals originalLength and removedCount is always 0 for accepted creatives — these fields
// exist for structural consistency with the shared result type but carry no signal here.
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);
// Fast path
const byId = document.getElementById(nid) as HTMLElement | null;
if (byId) return byId;
// Fallback for odd IDs (special chars) or if provided with quotes/etc.
try {
const selector = `[id="${nid.replace(/"/g, '\\"')}"]`;
const byAttr = document.querySelector(selector) as HTMLElement | null;
if (byAttr) return byAttr;
} catch {
// Ignore selector errors (e.g., invalid characters)
}
return null;
}
function ensureSlot(id: string): HTMLElement {
const nid = normalizeId(id);
let el = document.getElementById(nid) as HTMLElement | null;
if (el) return el;
el = document.createElement('div');
el.id = nid;
const body: HTMLElement | null = typeof document !== 'undefined' ? document.body : null;
if (body && typeof body.appendChild === 'function') {
body.appendChild(el);
} else {
// DOM not ready — attach once available
const element = el;
const onReady = () => {
const readyBody = document.body;
if (readyBody && !document.getElementById(nid) && element) readyBody.appendChild(element);
};
document.addEventListener('DOMContentLoaded', onReady, { once: true });
}
return el;
}
// Drop a placeholder message into the slot so pages don't sit empty pre-render.
export function renderAdUnit(codeOrUnit: string | AdUnit): void {
const code = typeof codeOrUnit === 'string' ? codeOrUnit : codeOrUnit?.code;
if (!code) return;
const unit = typeof codeOrUnit === 'string' ? getUnit(code) : codeOrUnit;
const size = (unit && firstSize(unit)) || [300, 250];
const el = ensureSlot(code);
try {
el.textContent = `Trusted Server — ${size[0]}x${size[1]}`;
log.info('renderAdUnit: rendered placeholder', { code, size });
} catch {
log.warn('renderAdUnit: failed', { code });
}
}
// Render placeholders for every registered ad unit (used in simple publisher demos).
export function renderAllAdUnits(): void {
try {
const parentReady =
typeof document !== 'undefined' && (document.body || document.documentElement);
if (!parentReady) {
log.warn('renderAllAdUnits: DOM not ready; skipping');
return;
}
const units = getAllUnits();
for (const u of units) {
renderAdUnit(u);
}
log.info('renderAllAdUnits: rendered all placeholders', { count: units.length });
} catch (e) {
log.warn('renderAllAdUnits: failed', e as unknown);
}
}
type IframeOptions = { name?: string; title?: string; width?: number; height?: number };
// Construct a sandboxed iframe sized for sanitized, non-executable creative HTML.
export function createAdIframe(
container: HTMLElement,
opts: IframeOptions = {}
): HTMLIFrameElement {
const iframe = document.createElement('iframe');
// Attributes
iframe.scrolling = 'no';
iframe.frameBorder = '0';
iframe.setAttribute('marginwidth', '0');
iframe.setAttribute('marginheight', '0');
if (opts.name) iframe.name = String(opts.name);
iframe.title = opts.title || 'Ad content';
iframe.setAttribute('aria-label', 'Advertisement');
// Sandbox permissions for creatives
try {
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);
const h = Math.max(0, Number(opts.height ?? 0) | 0);
if (w > 0) iframe.width = String(w);
if (h > 0) iframe.height = String(h);
const s = iframe.style;
s.setProperty('border', '0');
s.setProperty('margin', '0');
s.setProperty('overflow', 'hidden');
s.setProperty('display', 'block');
if (w > 0) s.setProperty('width', `${w}px`);
if (h > 0) s.setProperty('height', `${h}px`);
// Insert into container
container.appendChild(iframe);
return iframe;
}
// 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%',
() => creativeHtml
);
}