Skip to content

fix(widget): gate sandbox HTML delivery by nonce#3628

Open
lspassos1 wants to merge 3 commits into
koala73:mainfrom
lspassos1:fix/widget-postmessage-origin
Open

fix(widget): gate sandbox HTML delivery by nonce#3628
lspassos1 wants to merge 3 commits into
koala73:mainfrom
lspassos1:fix/widget-postmessage-origin

Conversation

@lspassos1
Copy link
Copy Markdown
Collaborator

@lspassos1 lspassos1 commented May 8, 2026

Summary

Closes #3544 by replacing the unconditional PRO widget iframe load-time HTML post with a nonce-gated sandbox handshake. The iframe remains isolated with sandbox="allow-scripts" and no allow-same-origin.

Root cause

mountProWidget() sent the generated widget document to iframe.contentWindow with targetOrigin: '*' whenever the iframe load event fired. If the iframe navigated before that load path completed, the parent could send widget HTML to an unexpected document.

Changes

  • Generate a per-widget nonce before loading /wm-widget-sandbox.html.
  • Pass id and nonce through the iframe URL fragment so they are not sent in HTTP referrers.
  • Have the sandbox send wm-widget-ready only to the parent origin derived from document.referrer.
  • Verify event.source and nonce in the parent before sending HTML.
  • Verify parent source, nonce, payload shape, and allowed origin in the sandbox before document.write().
  • Warn when the sandbox handshake is blocked by missing or disallowed parent origin, avoiding silent blank widgets during development and smoke testing.
  • Prune disconnected iframe entries from the mounted widget registry after mutation batches, so closed/replaced widgets do not retain full HTML documents.
  • Add regression coverage for the parent handshake, sandbox checks, and registry cleanup.

Notes

The final parent-to-sandbox postMessage still uses '*' because the sandbox intentionally has an opaque origin without allow-same-origin. Adding allow-same-origin would weaken the isolation. The trust boundary is now the source-checked, per-widget nonce handshake before any HTML is delivered.

Validation

  • node --test tests/widget-builder.test.mjs
  • npx biome check src/utils/widget-sanitizer.ts tests/widget-builder.test.mjs public/wm-widget-sandbox.html
  • npm run typecheck
  • npm run lint:unicode
  • git diff --check -- src/utils/widget-sanitizer.ts public/wm-widget-sandbox.html tests/widget-builder.test.mjs
  • Browser smoke test with a temporary local server verifying the sandbox handshake completes in Chrome

Risk

Medium-low. This is isolated to PRO widget iframe mounting. The main compatibility risk is PRO widget rendering if a browser blocks the handshake; the sandbox now warns for the known referrer-policy failure path, and the local browser smoke test covers the expected opaque-sandbox flow. Registry cleanup is deferred to the microtask after DOM mutations so same-cycle DOM moves keep their mounted entry.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 8, 2026

@lspassos1 is attempting to deploy a commit to the World Monitor Team on Vercel.

A member of the Team first needs to authorize it.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df271d17d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

widgetTokenStore.delete(id);
const html = buildWidgetDoc(body);
iframeHtmlStore.set(iframe, html);
mountedWidgetDocs.set(id, { iframe, html, token });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove mounted widget entry when iframe unmounts

Storing each PRO widget in mountedWidgetDocs without any removal path causes an unbounded memory leak because each entry holds a strong reference to the iframe plus its full HTML document. wrapProWidgetHtml() is called repeatedly in flows like preview rerenders (e.g., WidgetChatModal.renderPreviewHtml), so closed/replaced widgets keep accumulating in memory instead of being garbage-collected (the previous WeakMap behavior avoided this), which will degrade long-lived sessions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in 64f994e. The registry now queues removed PRO widget iframes from the MutationObserver and prunes the mounted entry in a microtask only if that same iframe remains disconnected. That preserves same-cycle DOM moves while avoiding strong retention of closed/replaced iframe HTML.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR closes a race condition where the PRO widget iframe could receive generated HTML from the parent unconditionally on every load event, potentially targeting an unintended document. It replaces that flow with a per-widget cryptographic nonce handshake: the sandbox sends wm-widget-ready back to the verified parent origin (derived from document.referrer), and the parent only delivers HTML after confirming the source window and nonce both match.

  • Nonce generation & handshake flow: wrapProWidgetHtml now generates a 128-bit crypto.getRandomValues token and passes it through the iframe URL fragment; mountedWidgetDocs stores the token alongside the iframe reference, and HTML is only dispatched once event.source and the token are verified.
  • Sandbox-side validation: wm-widget-sandbox.html now checks source, nonce, payload shape, and an allowlist origin regex before calling document.write, with a handled flag preventing re-delivery.
  • Memory behaviour change: the new Map-backed mountedWidgetDocs holds strong iframe references indefinitely (unlike the former WeakMap); entries are never deleted after the handshake completes, which can cause unbounded memory growth in long-running sessions with many widget mounts/unmounts.

Confidence Score: 4/5

Safe to merge for the security fix it delivers; the two quality concerns (post-handshake map cleanup and referrer-policy silent failure) do not block correctness in the standard deployment but warrant follow-up.

The nonce handshake logic is sound — source, token, and origin are all independently checked on both sides. The main residual concern is mountedWidgetDocs retaining strong iframe references after HTML delivery, replacing the WeakMap that previously allowed GC. In a long-running session with frequent panel drag/drop or remounting, this accumulates memory indefinitely. The secondary concern is the silent failure path when document.referrer is suppressed by a strict referrer policy, which would leave a widget permanently blank with no diagnostic output.

src/utils/widget-sanitizer.ts — specifically the handleWidgetSandboxReady function, which should delete the mountedWidgetDocs entry after delivering HTML. public/wm-widget-sandbox.html — the getParentOrigin fallback path deserves a console.warn so referrer-policy failures surface during development.

Important Files Changed

Filename Overview
src/utils/widget-sanitizer.ts Replaces load-event unconditional postMessage with a nonce-gated handshake: generates a per-widget crypto token, stores it alongside the iframe reference in mountedWidgetDocs, and only delivers HTML after verifying event.source and the token match. Entries in mountedWidgetDocs are never deleted post-handshake, holding strong iframe references for the page lifetime.
public/wm-widget-sandbox.html Adds origin and nonce validation before document.write: reads id/token from URL fragment, sends wm-widget-ready to the parent origin derived from document.referrer, and checks source, token, and origin before accepting the HTML payload. Silent failure risk if referrer policy strips document.referrer.
tests/widget-builder.test.mjs Adds regression tests asserting the presence of each nonce-handshake check in both the parent sanitizer and the sandbox page source. Coverage is pattern-based string matching rather than runtime execution, but it adequately guards against accidental deletion of the key guard clauses.

Sequence Diagram

sequenceDiagram
    participant Parent as Parent Window (widget-sanitizer.ts)
    participant Sandbox as wm-widget-sandbox.html (opaque origin)

    Note over Parent: wrapProWidgetHtml() generates widget-id and crypto token,<br/>stores body+token in widgetBodyStore/widgetTokenStore

    Parent->>Parent: MutationObserver detects iframe with data-wm-id
    Parent->>Parent: mountProWidget() stores iframe+html+token in mountedWidgetDocs
    Parent->>Sandbox: Sets iframe.src to sandbox URL with widget-id and token in hash fragment

    Note over Sandbox: Script reads widget-id and token from URL hash,<br/>registers wm-html message listener

    Sandbox->>Sandbox: getParentOrigin() reads document.referrer
    Sandbox-->>Parent: postMessage wm-widget-ready with widget-id and token to parentOrigin

    Note over Parent: handleWidgetSandboxReady() validates<br/>event.source matches stored iframe<br/>token matches stored token

    Parent->>Sandbox: "postMessage wm-html with widget-id, token, and HTML payload to *"

    Note over Sandbox: Validates source is window.parent<br/>Validates widget-id and token<br/>Validates html is a string<br/>Validates isAllowedParentOrigin(e.origin)

    Sandbox->>Sandbox: document.open/write/close — handled flag set to block re-delivery
Loading

Reviews (1): Last reviewed commit: "fix(widget): gate sandbox HTML delivery ..." | Re-trigger Greptile

Comment on lines +106 to +118
const mounted = mountedWidgetDocs.get(data.id);
if (!mounted || data.token !== mounted.token || event.source !== mounted.iframe.contentWindow) {
return;
}

// The sandbox deliberately omits allow-same-origin, so the child document has
// an opaque origin and cannot be targeted with location.origin. The per-widget
// token and source check above are the trust boundary before sending HTML.
mounted.iframe.contentWindow?.postMessage(
{ type: 'wm-html', id: data.id, token: mounted.token, html: mounted.html },
'*',
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 mountedWidgetDocs never cleaned up after handshake

After handleWidgetSandboxReady fires and delivers HTML to the iframe, the entry in mountedWidgetDocs is never removed. This keeps a strong reference to the HTMLIFrameElement alive for the lifetime of the page, preventing the iframe (and any sub-tree attached to it) from being garbage collected. The old implementation used a WeakMap<HTMLIFrameElement, string> which correctly allowed GC when the iframe was removed from the DOM. Since the panel layout supports drag-and-drop and widget removal, many entries can accumulate over a user session.

Calling mountedWidgetDocs.delete(data.id) after the postMessage inside handleWidgetSandboxReady (i.e., once the HTML has been delivered) would restore the previous GC-friendly behaviour. The handled guard on the sandbox side already prevents a second document.write if the parent somehow re-delivers, so deleting the entry is safe.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed across 64f994e and eb258c0. I kept the entry while the iframe is still connected so sandbox reloads after DOM moves can request the same HTML again, but the MutationObserver now queues removed iframes and deletes the mounted entry in a microtask if that exact iframe remains disconnected. That avoids retaining closed/replaced iframe HTML without breaking same-cycle reparenting.

Comment on lines +20 to +47
function getParentOrigin() {
try {
return new URL(document.referrer).origin;
} catch (_err) {
return '';
}
}

window.addEventListener('message', (e) => {
if (handled) return;
if (!e.data || e.data.type !== 'wm-html') return;
if (e.source !== window.parent) return;
if (e.data.id !== id || e.data.token !== token) return;
if (typeof e.data.html !== 'string') return;

var origin = e.origin || '';
if (!origin.endsWith('worldmonitor.app') && !/^https?:\/\/localhost/.test(origin)) return;
if (!isAllowedParentOrigin(origin)) return;

handled = true;
document.open();
document.write(e.data.html);
document.close();
});
}());

var parentOrigin = getParentOrigin();
if (id && token && parentOrigin && isAllowedParentOrigin(parentOrigin)) {
window.parent.postMessage({ type: 'wm-widget-ready', id: id, token: token }, parentOrigin);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Handshake silently fails if parent's referrer policy strips the referrer

getParentOrigin() derives the parent's origin from document.referrer. If the parent page (or the server response) specifies Referrer-Policy: no-referrer or no-referrer-when-downgrade, the iframe receives an empty document.referrer. The guard if (id && token && parentOrigin && isAllowedParentOrigin(parentOrigin)) then evaluates to false, so wm-widget-ready is never posted, the parent never sends HTML, and the widget renders blank with no console error.

This is a known trade-off of opaque-origin sandboxing (there's no other way to discover the parent's origin without allow-same-origin), but the failure mode is completely silent. Consider adding a console.warn when parentOrigin is empty so that the handshake break surfaces during development or smoke testing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed in eb258c0. The sandbox now emits a console warning when the ready-message handshake is blocked by missing or disallowed parent origin, so a referrer-policy break no longer fails silently during development or smoke testing.

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.

[SECURITY] widget-sanitizer postMessage uses '*' targetOrigin instead of explicit origin

1 participant