Conversation
aram356
left a comment
There was a problem hiding this comment.
Summary
Solid security hardening — removing allow-scripts/allow-same-origin from the sandbox, rejecting dangerous creatives rather than silently sanitizing, and fixing String.replace $-sequence injection. However, slot clearing before validation creates a regression where rejected bids blank the slot, and the URI detection has gaps that could cause both false negatives (data:image/svg+xml) and false positives (benign URI attribute removals).
Blocking
🔧 wrench
- Slot blanking on rejection:
container.innerHTML = ''runs before sanitization — rejected creatives destroy existing slot content. In multi-bid scenarios, a later rejected bid erases an earlier successful render. (request.ts:96) - Missing
bid.admguard: Thebid.adm &&check frommainwas removed, so bids with missing/empty/malformedadmenter the render path, clear the slot, then get rejected. (request.ts:51) - Narrow
data:URI pattern: Only blocksdata:text/html, missingdata:text/xml,data:application/xhtml+xml, anddata:image/svg+xml(SVG can embed<script>). (render.ts:35) - Over-aggressive URI attribute flagging:
isDangerousRemovalflags any removed URI attribute as dangerous regardless of value, causing false rejections for benign creatives. Inconsistent withhasDangerousMarkupwhich correctly checks the value. (render.ts:108)
Non-blocking
🤔 thinking
- 3.8x bundle size increase: DOMPurify is statically imported into
tsjs-core.js(8,964 B → 34,160 B raw, 3,788 B → 12,940 B gzip). The build usesinlineDynamicImports: trueso lazyimport()won't help. Since the policy is reject-only,hasDangerousMarkup(native<template>parser) already does the full detection. Consider removing DOMPurify entirely or moving sanitization server-side. - Static-only creative contract without rollout guard: Removing
allow-scripts+allow-same-originand rejecting script-bearing markup is a major behavioral shift. Most DSP creatives use JavaScript for tracking, viewability, and click handling. Consider a strict-render feature flag (default off) with rejection metrics, rolled out by seat/publisher.
♻️ refactor
- Inconsistent sandbox policy:
<form>is inDANGEROUS_TAG_NAMES(rejected) butallow-formsis inCREATIVE_SANDBOX_TOKENS(permitted). Removeallow-formsor stop rejecting<form>. (render.ts:38) hasDangerousMarkuplacks intent documentation: The post-sanitization re-scan is a valid safety net for sanitizer bugs, but the comment doesn't explain why DOMPurify output is being re-scanned. (render.ts:119)
⛏ nitpick
srcdocinURI_ATTRIBUTE_NAMES:srcdocis HTML content, not a URI. DOMPurify already strips it. (render.ts:33)
🌱 seedling
- Missing test coverage: (1) multi-bid same slot where one bid is rejected, (2) sanitizer-unavailable path, (3)
data:image/svg+xmlwith embedded script, (4) explicit test documenting script-based creatives are intentionally rejected.
👍 praise
buildCreativeDocument$-sequence fix: Function callbacks inString.replaceprevent replacement pattern injection. Well-tested. (render.ts:337)- Structured rejection logging: Rejection logs include metadata without leaking raw creative HTML. Tests verify no raw HTML in log output. (
request.ts:100)
CI Status
- cargo fmt: PASS
- cargo test: PASS
- vitest: PASS
- format-typescript: PASS
- format-docs: PASS
- CodeQL: PASS
| try { | ||
| // Clear previous content | ||
| // Clear the slot before render so rejected creatives fail closed with no stale markup left behind. | ||
| container.innerHTML = ''; |
There was a problem hiding this comment.
🔧 wrench — Slot content is cleared before validation; rejected bids blank the slot.
container.innerHTML = '' runs before sanitizeCreativeHtml. When a creative is rejected, the slot is left blank — destroying any existing placeholder or previously rendered ad. In multi-bid scenarios, a later rejected bid for the same slot erases an earlier successful render.
Fix: Move container.innerHTML = '' after sanitization succeeds, just before createAdIframe.
| for (const bid of bids) { | ||
| if (bid.impid && bid.adm) { | ||
| renderCreativeInline(bid.impid, bid.adm, bid.width, bid.height); | ||
| if (bid.impid) { |
There was a problem hiding this comment.
🔧 wrench — All bids with impid are attempted, even without adm.
The bid.adm && check from main was removed. Bids with missing/empty/malformed adm now enter the render path, clear the slot (see comment above), then get rejected — leaving a blank slot where a placeholder or previous ad was.
Fix: Restore the bid.adm truthiness check:
if (bid.impid && bid.adm) {| 'srcdoc', | ||
| 'xlink:href', | ||
| ]); | ||
| const DANGEROUS_URI_VALUE_PATTERN = /^\s*(?:javascript:|vbscript:|data\s*:\s*text\/html\b)/i; |
There was a problem hiding this comment.
🔧 wrench — DANGEROUS_URI_VALUE_PATTERN is too narrow for data: URIs.
Only blocks data:text/html. Misses dangerous MIME types that can execute scripts:
data:text/xmldata:application/xhtml+xmldata:image/svg+xml(SVG can embed<script>elements)
Ad creatives have no legitimate need for data: URIs.
Fix:
const DANGEROUS_URI_VALUE_PATTERN = /^\s*(?:javascript:|vbscript:|data\s*:)/i;| return true; | ||
| } | ||
|
|
||
| if (URI_ATTRIBUTE_NAMES.has(attrName)) { |
There was a problem hiding this comment.
🔧 wrench — isDangerousRemoval over-flags all removed URI attributes regardless of value.
Any removed attribute in URI_ATTRIBUTE_NAMES is treated as dangerous. DOMPurify may strip a src or href for benign reasons (malformed URL, data:image/png). This creates false rejections for legitimate creatives.
Compare with hasDangerousMarkup (line 142) which correctly checks the attribute value against DANGEROUS_URI_VALUE_PATTERN. The two checks are inconsistent.
Fix: Check the removed value, not just the attribute name:
if (URI_ATTRIBUTE_NAMES.has(attrName) && DANGEROUS_URI_VALUE_PATTERN.test(attrValue)) {
return true;
}| 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', |
There was a problem hiding this comment.
♻️ refactor — Sandbox policy is internally inconsistent.
form is in DANGEROUS_TAG_NAMES (creatives with <form> are rejected), but allow-forms is in CREATIVE_SANDBOX_TOKENS (form submission is permitted in the sandbox). These contradict each other.
Suggestion: Remove allow-forms from the sandbox tokens to match the rejection policy. Or, if form creatives should be supported, remove form from DANGEROUS_TAG_NAMES.
| return false; | ||
| } | ||
|
|
||
| function hasDangerousMarkup(candidateHtml: string): boolean { |
There was a problem hiding this comment.
♻️ refactor — hasDangerousMarkup post-sanitization scan lacks intent documentation.
This re-parses DOMPurify's output for the same patterns DOMPurify should have already handled. It's a valid safety net — especially for CSS expression() in <style> elements that DOMPurify's default config allows through — but the comment doesn't explain why the output of a sanitizer is being re-scanned.
Suggestion: Add a comment clarifying the defense-in-depth intent, e.g.: "Safety net: re-scan after sanitization to catch patterns DOMPurify may allow through (e.g., CSS expressions) or sanitizer bugs."
| 'poster', | ||
| 'src', | ||
| 'srcdoc', | ||
| 'xlink:href', |
There was a problem hiding this comment.
⛏ nitpick — srcdoc is HTML content, not a URI.
srcdoc doesn't belong in URI_ATTRIBUTE_NAMES. DOMPurify already strips it (only valid on <iframe>, which is in DANGEROUS_TAG_NAMES). Having it here is semantically wrong and could cause false positives if isDangerousRemoval is refined.
| // 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( |
There was a problem hiding this comment.
👍 praise — Good catch on String.replace $-sequence injection.
Using function callbacks prevents $&, $$, $', etc. from being interpreted as replacement patterns. Well-tested with the dollar sequences test case.
|
|
||
| const sanitization = sanitizeCreativeHtml(creativeHtml); | ||
| if (sanitization.kind === 'rejected') { | ||
| log.warn('renderCreativeInline: rejected creative', { |
There was a problem hiding this comment.
👍 praise — Structured rejection logging without raw HTML.
Rejection logs include slotId, seat, creativeId, and rejectionReason without leaking raw creative HTML. Tests explicitly verify no raw HTML appears in log output — good security practice.
Summary
requestAdsrenderer so untrusted inline creatives cannot escape the iframe sandbox or execute retained dangerous markup.Changes
crates/js/lib/package.jsondompurifyas a runtime dependency for core creative sanitization.crates/js/lib/package-lock.jsoncrates/js/lib/src/core/render.tscrates/js/lib/src/core/request.tssrcdocinjection and add structured render/rejection logging metadata.crates/js/lib/test/core/render.test.tscrates/js/lib/test/core/request.test.tsCloses
Closes #401
Test plan
cargo test --workspacecargo clippy --all-targets --all-features -- -D warningscargo fmt --all -- --checkcd crates/js/lib && npx vitest runcd crates/js/lib && npm run formatcd docs && npm run formatcargo build --bin trusted-server-fastly --release --target wasm32-wasip1fastly compute servecd crates/js/lib && npm run buildChecklist
unwrap()in production code — useexpect("should ...")tracingmacros (notprintln!)