Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/threads-window-origin-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@quilted/threads': minor
---

`ThreadWindow`'s `targetOrigin` option now also validates the `origin` of incoming messages, and gained an `'ancestor'` mode.

Previously `targetOrigin` only set the origin for outgoing `postMessage()` calls; inbound messages were accepted from any origin as long as they came from the expected window. It is now also used to reject messages whose `origin` does not match, so a concrete `targetOrigin` authenticates both directions:

```ts
const thread = ThreadWindow.iframe(iframe, {
targetOrigin: 'https://embed.my-app.com',
exports: {
/* ... */
},
});
```

The default remains `'*'` (post to, and accept from, any origin), so existing behaviour is unchanged.

You can also pass `targetOrigin: 'ancestor'` to pin to the origin that framed the current window — read from `location.ancestorOrigins`, falling back to the origin of the first received message on platforms without that API (e.g. Firefox), buffering outgoing messages until it is known. This is convenient inside an `iframe` that trusts its embedder but doesn't know the embedder's origin ahead of time:

```ts
const thread = ThreadWindow.parent({targetOrigin: 'ancestor'});
```
17 changes: 17 additions & 0 deletions packages/threads/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ const {message} = await thread.imports.connect();

As shown above, you will typically want the “child” window — in this case, the iframe — to import a method provided by the parent window, and then call that method to start the conversation. You may also export methods from the child and call them in the parent, but you must write your own logic to ensure the child window is ready to receive the message.

When you communicate across origins, pass a `targetOrigin` so the thread only talks to the window you trust. It is used both as the `targetOrigin` of outgoing `postMessage()` calls and to validate the `origin` of every incoming message — messages from any other origin are ignored. By default `targetOrigin` is `'*'`, which posts to and accepts messages from any origin, so prefer a concrete origin whenever you know it:

```ts
// In the parent — you know the iframe’s origin:
const thread = ThreadWindow.iframe(iframe, {
targetOrigin: 'https://embed.my-app.com',
exports: {
/* ... */
},
});

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

`'ancestor'` pins to the origin of the document that framed the current window, read from [`location.ancestorOrigins`](https://developer.mozilla.org/en-US/docs/Web/API/Location/ancestorOrigins). On platforms that don’t support `ancestorOrigins` (notably Firefox), the origin of the first message received is trusted instead, and outgoing messages are buffered until that origin is known. Restricting which origins may frame your page (for example, with a [`frame-ancestors` CSP directive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors)) complements this check.

You can also use `ThreadWindow` to create a thread between a parent window and a popup or separate tab it opens:

```ts
Expand Down
129 changes: 129 additions & 0 deletions packages/threads/source/tests/window.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {describe, it, expect, vi, afterEach} from 'vitest';

import {windowToThreadTarget} from '../threads/ThreadWindow.ts';

afterEach(() => {
vi.unstubAllGlobals();
});

/**
* Stubs the global `self` with a fake that captures the `message` listener, so
* tests can synthesize inbound `MessageEvent`s, and exposes a configurable
* `location.ancestorOrigins`.
*/
function stubSelf({
ancestorOrigins,
}: {ancestorOrigins?: string[] | undefined} = {}) {
let handler: ((event: any) => void) | undefined;

vi.stubGlobal('self', {
location: ancestorOrigins == null ? {} : {ancestorOrigins},
addEventListener(type: string, listener: (event: any) => void) {
if (type === 'message') handler = listener;
},
});

return {
dispatch(event: {source: unknown; origin: string; data: unknown}) {
handler?.(event);
},
};
}

function fakeWindow() {
return {postMessage: vi.fn()} as unknown as Window & {
postMessage: ReturnType<typeof vi.fn>;
};
}

describe('windowToThreadTarget', () => {
it('posts to and accepts any origin by default', () => {
const {dispatch} = stubSelf();
const window = fakeWindow();
const target = windowToThreadTarget(window);

const received: unknown[] = [];
target.listen((value) => received.push(value), {});

target.send('out');
expect(window.postMessage).toHaveBeenCalledWith('out', '*', undefined);

dispatch({source: window, origin: 'https://anywhere.example', data: 'in'});
expect(received).toEqual(['in']);
});

it('pins outbound and validates inbound against a concrete origin', () => {
const origin = 'https://trusted.example';
const {dispatch} = stubSelf();
const window = fakeWindow();
const target = windowToThreadTarget(window, {targetOrigin: origin});

const received: unknown[] = [];
target.listen((value) => received.push(value), {});

target.send('out');
expect(window.postMessage).toHaveBeenCalledWith('out', origin, undefined);

dispatch({source: window, origin, data: 'trusted'});
dispatch({source: window, origin: 'https://evil.example', data: 'spoofed'});

expect(received).toEqual(['trusted']);
});

it('ignores messages whose source is not the target window', () => {
const {dispatch} = stubSelf();
const window = fakeWindow();
const target = windowToThreadTarget(window);

const received: unknown[] = [];
target.listen((value) => received.push(value), {});

dispatch({source: {}, origin: 'https://anywhere.example', data: 'other'});
expect(received).toEqual([]);
});

it('pins to ancestorOrigins when targetOrigin is "ancestor"', () => {
const framer = 'https://framer.example';
const {dispatch} = stubSelf({ancestorOrigins: [framer]});
const window = fakeWindow();
const target = windowToThreadTarget(window, {targetOrigin: 'ancestor'});

const received: unknown[] = [];
target.listen((value) => received.push(value), {});

target.send('out');
expect(window.postMessage).toHaveBeenCalledWith('out', framer, undefined);

dispatch({source: window, origin: framer, data: 'trusted'});
dispatch({source: window, origin: 'https://evil.example', data: 'spoofed'});
expect(received).toEqual(['trusted']);
});

it('buffers then pins to the first sender when ancestorOrigins is unavailable', () => {
const framer = 'https://framer.example';
// No `ancestorOrigins` (e.g. Firefox).
const {dispatch} = stubSelf({ancestorOrigins: undefined});
const window = fakeWindow();
const target = windowToThreadTarget(window, {targetOrigin: 'ancestor'});

const received: unknown[] = [];
target.listen((value) => received.push(value), {});

// Sent before the origin is known: must be buffered, not posted to '*'.
target.send('early');
expect(window.postMessage).not.toHaveBeenCalled();

// First inbound message pins the origin and flushes the buffer.
dispatch({source: window, origin: framer, data: 'first'});
expect(window.postMessage).toHaveBeenCalledWith('early', framer, undefined);
expect(received).toEqual(['first']);

// Subsequent messages from other origins are rejected.
dispatch({source: window, origin: 'https://evil.example', data: 'spoofed'});
expect(received).toEqual(['first']);

// Later outbound messages go straight to the pinned origin.
target.send('later');
expect(window.postMessage).toHaveBeenCalledWith('later', framer, undefined);
});
});
79 changes: 72 additions & 7 deletions packages/threads/source/threads/ThreadWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,25 @@ export interface ThreadWindowOptions<
Imports = Record<string, never>,
Exports = Record<string, never>,
> extends ThreadOptions<Imports, Exports> {
/**
* Restricts the origin this thread communicates with. The value is 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 messages from, any origin. This is
* convenient but performs no authentication of the other side, so prefer a
* concrete origin whenever you know it.
* - `'ancestor'`: pin to the origin of the document that framed this window
* (read from `location.ancestorOrigins`). Useful from inside an `iframe`
* when you trust whoever embedded you but don't know their origin ahead of
* time. On platforms without `ancestorOrigins` (e.g. Firefox), the origin
* of the first received message is trusted instead.
* - any other value: a specific origin (e.g. `'https://my-app.com'`) to post
* to and require on incoming messages.
*
* @default '*'
*/
targetOrigin?: string;
}

Expand Down Expand Up @@ -431,26 +450,72 @@ export function windowToThreadTarget(
window: Window,
{targetOrigin = '*'}: {targetOrigin?: string} = {},
): ThreadMessageTarget {
const sendMessage: ThreadMessageTarget['send'] = function send(
message,
transfer,
) {
window.postMessage(message, targetOrigin, transfer);
};
// The origin we will `postMessage()` to, and that inbound messages are
// required to come from. How it is resolved depends on `targetOrigin`:
//
// - `'*'` (default): post to any origin, and accept messages from any origin.
// This preserves the original, unauthenticated behaviour.
// - `'ancestor'`: pin to the origin that framed *this* window, read from
// `location.ancestorOrigins`. When that API is unavailable (notably
// Firefox), pin to the origin of the first message received instead,
// buffering outbound messages until the origin is known.
// - any other value: a concrete origin to post to, and to require on every
// inbound message.
let resolvedOrigin: string | null =
targetOrigin === 'ancestor' ? (ancestorOrigin() ?? null) : targetOrigin;

// Outbound messages attempted before the target origin is known. Only
// populated in `'ancestor'` mode on platforms without `ancestorOrigins`,
// then flushed once the first inbound message pins the origin.
const buffered: Parameters<ThreadMessageTarget['send']>[] = [];

return {
send(message, transfer) {
return sendMessage(message, transfer);
if (resolvedOrigin == null) {
buffered.push([message, transfer]);
return;
}

window.postMessage(message, resolvedOrigin, transfer);
},
listen(listen, {signal}) {
self.addEventListener(
'message',
(event) => {
if (event.source !== window) return;

if (resolvedOrigin == null) {
// First message in `'ancestor'` mode without `ancestorOrigins`:
// trust this sender, then flush anything buffered for it.
resolvedOrigin = event.origin;

for (const [message, transfer] of buffered) {
window.postMessage(message, resolvedOrigin, transfer);
}
buffered.length = 0;
} else if (
resolvedOrigin !== '*' &&
event.origin !== resolvedOrigin
) {
// Reject messages from any origin other than the trusted one.
return;
}

listen(event.data);
},
{signal},
);
},
};
}

/**
* The origin of the document that framed the current window, if the platform
* exposes it through `location.ancestorOrigins`. Returns `undefined` when the
* current window has no ancestor (it is top-level) or the API is unavailable.
*/
function ancestorOrigin(): string | undefined {
if (typeof self === 'undefined') return undefined;
const ancestors = (self.location as Location | undefined)?.ancestorOrigins;
return ancestors && ancestors.length > 0 ? ancestors[0] : undefined;
}
Loading