Skip to content

Harden inline creative rendering#459

Open
prk-Jr wants to merge 2 commits intomainfrom
fix/401-harden-inline-creative-rendering
Open

Harden inline creative rendering#459
prk-Jr wants to merge 2 commits intomainfrom
fix/401-harden-inline-creative-rendering

Conversation

@prk-Jr
Copy link
Collaborator

@prk-Jr prk-Jr commented Mar 7, 2026

Summary

  • Harden the core requestAds renderer so untrusted inline creatives cannot escape the iframe sandbox or execute retained dangerous markup.
  • Fail closed on malformed or sanitized-away creatives with structured rejection metadata while avoiding raw creative HTML in logs.
  • Add regression coverage for sandbox permissions, dangerous URI/style payloads, malformed creatives, and accepted safe markup.

Changes

File Change
crates/js/lib/package.json Add dompurify as a runtime dependency for core creative sanitization.
crates/js/lib/package-lock.json Lock the new DOMPurify dependency and its transitive package metadata.
crates/js/lib/src/core/render.ts Sanitize untrusted creative HTML, reject malformed or dangerous markup, and tighten iframe sandbox permissions.
crates/js/lib/src/core/request.ts Route every inline creative through the sanitizer before srcdoc injection and add structured render/rejection logging metadata.
crates/js/lib/test/core/render.test.ts Cover sandbox tokens, accepted safe markup, rejected dangerous URI/style payloads, malformed creatives, and empty sanitization results.
crates/js/lib/test/core/request.test.ts Cover safe request-path rendering plus fail-closed behavior for dangerous, malformed, and empty creatives without logging raw HTML.

Closes

Closes #401

Test plan

  • cargo test --workspace
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo fmt --all -- --check
  • JS tests: cd crates/js/lib && npx vitest run
  • JS format: cd crates/js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --bin trusted-server-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: cd crates/js/lib && npm run build

Checklist

  • Changes follow CLAUDE.md conventions
  • No unwrap() in production code — use expect("should ...")
  • Uses tracing macros (not println!)
  • New code has tests
  • No secrets or credentials committed

@prk-Jr prk-Jr self-assigned this Mar 7, 2026
Copy link
Collaborator

@aram356 aram356 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.adm guard: The bid.adm && check from main was removed, so bids with missing/empty/malformed adm enter the render path, clear the slot, then get rejected. (request.ts:51)
  • Narrow data: URI pattern: Only blocks data:text/html, missing data:text/xml, data:application/xhtml+xml, and data:image/svg+xml (SVG can embed <script>). (render.ts:35)
  • Over-aggressive URI attribute flagging: isDangerousRemoval flags any removed URI attribute as dangerous regardless of value, causing false rejections for benign creatives. Inconsistent with hasDangerousMarkup which 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 uses inlineDynamicImports: true so lazy import() 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-origin and 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 in DANGEROUS_TAG_NAMES (rejected) but allow-forms is in CREATIVE_SANDBOX_TOKENS (permitted). Remove allow-forms or stop rejecting <form>. (render.ts:38)
  • hasDangerousMarkup lacks 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

  • srcdoc in URI_ATTRIBUTE_NAMES: srcdoc is 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+xml with embedded script, (4) explicit test documenting script-based creatives are intentionally rejected.

👍 praise

  • buildCreativeDocument $-sequence fix: Function callbacks in String.replace prevent 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 = '';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchDANGEROUS_URI_VALUE_PATTERN is too narrow for data: URIs.

Only blocks data:text/html. Misses dangerous MIME types that can execute scripts:

  • data:text/xml
  • data:application/xhtml+xml
  • data: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)) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrenchisDangerousRemoval 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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ 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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactorhasDangerousMarkup 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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpicksrcdoc 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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 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', {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unsanitized creative HTML injected into iframe with weakened sandbox

2 participants