Skip to content

[threads] Validate message origin in ThreadWindow, add "ancestor" mode#968

Merged
lemonmade merged 2 commits into
mainfrom
threads-window-origin-validation
Jun 26, 2026
Merged

[threads] Validate message origin in ThreadWindow, add "ancestor" mode#968
lemonmade merged 2 commits into
mainfrom
threads-window-origin-validation

Conversation

@lemonmade

Copy link
Copy Markdown
Owner

Motivation

ThreadWindow's targetOrigin only controlled the outgoing postMessage() origin. Inbound messages were accepted from any origin as long as event.source matched the expected window — so a concrete targetOrigin did not actually authenticate the other side, and there was no built-in way to safely talk to "whoever framed me" without hard-coding their origin.

This came up wiring a cross-origin live-preview thread between an admin editor and the iframe it embeds: to get an authenticated channel I had to hand-roll a ThreadMessageTarget that (a) validates event.origin and (b) discovers + pins the framing origin. That logic belongs in the library.

What changed

windowToThreadTarget (and therefore ThreadWindow / .iframe / .parent / .opener via the targetOrigin option) now treats targetOrigin as a two-way restriction:

  • '*' (default) — post to, and accept messages from, any origin. Unchanged, so this is non-breaking.
  • a concrete origin (e.g. 'https://embed.my-app.com') — post only to it, and drop inbound messages whose event.origin doesn't match.
  • 'ancestor' (new) — pin to the origin that framed the current window, read from location.ancestorOrigins. When that API is unavailable (notably Firefox), the origin of the first received message is trusted instead, and outbound messages are buffered until the origin is known.
// Parent — you know the iframe's origin:
const thread = ThreadWindow.iframe(iframe, {
  targetOrigin: 'https://embed.my-app.com',
  exports: {/* ... */},
});

// Inside the iframe — trust your embedder without hard-coding their origin:
const thread = ThreadWindow.parent({targetOrigin: 'ancestor'});

A frame-ancestors CSP directive remains the right complement for restricting who may frame you; this adds the per-message origin check on top.

Interface

ThreadWindowOptions.targetOrigin (packages/threads/source/threads/ThreadWindow.ts)
export interface ThreadWindowOptions<Imports, Exports>
  extends ThreadOptions<Imports, Exports> {
  /**
   * Restricts the origin this thread communicates with. Used both as the
   * `targetOrigin` for outgoing `postMessage()` calls, and to validate the
   * `origin` of every incoming message — messages from any other origin are
   * ignored.
   *
   * - `'*'` (default): post to, and accept from, any origin.
   * - `'ancestor'`: pin to the origin that framed this window
   *   (`location.ancestorOrigins`), falling back to the first received
   *   message's origin where that API is unavailable.
   * - any other value: a specific origin to post to and require on inbound.
   *
   * @default '*'
   */
  targetOrigin?: string;
}

// unchanged signature; richer semantics:
export function windowToThreadTarget(
  window: Window,
  options?: {targetOrigin?: string},
): ThreadMessageTarget;

Tests

packages/threads/source/tests/window.test.ts covers the default permissive behaviour, concrete-origin pinning + inbound rejection, event.source filtering, 'ancestor' via ancestorOrigins, and the buffer-then-pin-to-first-sender fallback. All green.

Changeset

@quilted/threads: minor (additive; default behaviour unchanged).

🤖 Generated with Claude Code

lemonmade and others added 2 commits June 26, 2026 09:24
`ThreadWindow`'s `targetOrigin` previously only set the origin for outgoing
`postMessage()` calls; inbound messages were accepted from any origin as long
as they came from the expected window. `windowToThreadTarget` now also rejects
messages whose `origin` does not match `targetOrigin`, so a concrete origin
authenticates both directions. The default stays `'*'` (unchanged behaviour).

Adds a `targetOrigin: 'ancestor'` mode that pins to the origin that framed the
current window (from `location.ancestorOrigins`), falling back to the origin of
the first received message on platforms without that API (e.g. Firefox) and
buffering outgoing messages until it is known. This is the common "I'm in an
iframe and trust whoever embedded me, but don't know their origin" case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lemonmade
lemonmade merged commit b17b028 into main Jun 26, 2026
8 checks passed
@lemonmade lemonmade mentioned this pull request Jun 26, 2026
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.

1 participant