Skip to content

Harden inline creative rendering#459

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

Harden inline creative rendering#459
prk-Jr wants to merge 4 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

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) {

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;
}

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

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

Copy link
Collaborator

@ChristianPavilonis ChristianPavilonis left a comment

Choose a reason for hiding this comment

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

No new findings in this review pass.

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

3 participants