From 050d0b19c928e98b1c8988ff395d45f1cb054ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Mon, 31 Aug 2026 22:53:57 +0200 Subject: [PATCH] fix: give each provider scope its own message channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inpage providers and their content scripts talked to each other with `window.postMessage` on the page window. Every message that went over it — request ids, arguments and replies alike — was delivered to all the other message listeners in the frame, and the first reply carrying a matching id settled the call. Each scope now gets a `MessageChannel`. The content script keeps one end and transfers the other to the inpage world, and provider requests, replies and events travel over that channel instead of the page window. - `messagePortServer.js` creates the channel and hands over the port. It runs at module top, ahead of the asynchronous should-inject decision, and buffers requests that arrive before the scope registers its handler. - `postMessage.ts` asks for its port, retries until it arrives (either world may start first) and routes replies back to the caller by id. - replies for an id that is already settled are reported rather than applied. - `accountChanged` is delivered over the channel instead of the page window. The handover is negotiated with window messages, so the channel is private only from scripts that are not yet running when the port is transferred. On MV2 the inpage script is injected inline at document_start and that ordering holds; on MV3 the main-world script is registered separately and it is not guaranteed. Tests cover both sides of the transport. JSDOM has no MessageChannel, so `tests/unit/helpers/fakeMessageChannel.ts` provides a stand-in that is installed per test file — msw relies on the real one, so it is not replaced globally. --- .../__tests__/messagePortServer.test.ts | 182 +++++++++++++++ src/extension/content-script/alby.js | 122 +++++----- src/extension/content-script/liquid.js | 125 +++++------ .../content-script/messagePortServer.js | 98 ++++++++ src/extension/content-script/nostr.js | 139 +++++------- src/extension/content-script/webbtc.js | 125 +++++------ src/extension/content-script/webln.js | 131 +++++------ src/extension/inpage-script/index.js | 12 +- .../providers/__tests__/postMessage.test.ts | 210 ++++++++++++++++++ src/extension/providers/postMessage.ts | 200 +++++++++++++---- tests/unit/helpers/fakeMessageChannel.ts | 66 ++++++ 11 files changed, 990 insertions(+), 420 deletions(-) create mode 100644 src/extension/content-script/__tests__/messagePortServer.test.ts create mode 100644 src/extension/content-script/messagePortServer.js create mode 100644 src/extension/providers/__tests__/postMessage.test.ts create mode 100644 tests/unit/helpers/fakeMessageChannel.ts diff --git a/src/extension/content-script/__tests__/messagePortServer.test.ts b/src/extension/content-script/__tests__/messagePortServer.test.ts new file mode 100644 index 0000000000..33ea5e5ba1 --- /dev/null +++ b/src/extension/content-script/__tests__/messagePortServer.test.ts @@ -0,0 +1,182 @@ +import { installFakeMessageChannel } from "../../../../tests/unit/helpers/fakeMessageChannel"; + +let restoreMessageChannel: () => void; +beforeAll(() => { + restoreMessageChannel = installFakeMessageChannel(); +}); +afterAll(() => { + restoreMessageChannel(); +}); + +// The isolated-world side of the provider transport: it hands one end of a +// channel to the inpage world and services requests that arrive over it. + +type PortLike = { + onmessage: ((ev: { data: unknown }) => void) | null; + postMessage: (data: unknown) => void; + start: () => void; +}; + +// Every createScopePort() adds a window listener that lives for the rest of the +// file, so each test uses its own scope name and only its own server answers. +let scopeCounter = 0; +const nextScope = () => `webln-${++scopeCounter}`; + +async function loadServer() { + let mod!: typeof import("../messagePortServer"); + await jest.isolateModulesAsync(async () => { + mod = await import("../messagePortServer"); + }); + return mod; +} + +// Play the inpage side: ask for the port and take the one that is transferred. +function requestPort(scope: string): PortLike { + const posted: unknown[][] = []; + const spy = jest + .spyOn(window, "postMessage") + .mockImplementation((...args: unknown[]) => { + posted.push(args); + }); + + window.dispatchEvent( + new MessageEvent("message", { + data: { application: "LBE", type: "lbe:port-request", scope }, + source: window, + }) + ); + + spy.mockRestore(); + const transfer = posted.find( + (args) => + (args[0] as Record)?.type === "lbe:port" && + (args[0] as Record)?.scope === scope + ); + expect(transfer).toBeDefined(); + const ports = transfer?.[2] as PortLike[]; + expect(ports).toHaveLength(1); + const port = ports[0]; + port.start(); + return port; +} + +function nextMessage(port: PortLike): Promise> { + return new Promise((resolve) => { + port.onmessage = (ev) => resolve(ev.data as Record); + }); +} + +describe("createScopePort", () => { + test("services a request that arrives over the port and replies on it", async () => { + const { createScopePort } = await loadServer(); + const scope = nextScope(); + const transport = createScopePort(scope); + const port = requestPort(scope); + + transport.onRequest( + (data: Record, reply: (r: unknown) => void) => { + expect(data.action).toBe("webln/getInfo"); + reply({ data: { node: "genuine" } }); + } + ); + + const reply = nextMessage(port); + port.postMessage({ id: "1", application: "LBE", action: "webln/getInfo" }); + + await expect(reply).resolves.toMatchObject({ + id: "1", + scope, + response: true, + data: { data: { node: "genuine" } }, + }); + }); + + test("buffers requests that arrive before a handler is registered", async () => { + const { createScopePort } = await loadServer(); + const scope = nextScope(); + const transport = createScopePort(scope); + const port = requestPort(scope); + + port.postMessage({ + id: "early", + application: "LBE", + action: "webln/getInfo", + }); + await new Promise((r) => setTimeout(r, 10)); + + const seen: string[] = []; + const reply = nextMessage(port); + transport.onRequest( + (data: Record, r: (v: unknown) => void) => { + seen.push(data.id as string); + r({ data: {} }); + } + ); + + await expect(reply).resolves.toMatchObject({ id: "early" }); + expect(seen).toEqual(["early"]); + }); + + test("transfers the port only once", async () => { + const { createScopePort } = await loadServer(); + const scope = nextScope(); + createScopePort(scope); + requestPort(scope); + + // a second request finds nothing left to transfer + const posted: unknown[][] = []; + const spy = jest + .spyOn(window, "postMessage") + .mockImplementation((...args: unknown[]) => { + posted.push(args); + }); + window.dispatchEvent( + new MessageEvent("message", { + data: { application: "LBE", type: "lbe:port-request", scope }, + source: window, + }) + ); + spy.mockRestore(); + expect(posted).toHaveLength(0); + }); + + test("ignores handshake requests for another scope", async () => { + const { createScopePort } = await loadServer(); + createScopePort(nextScope()); + + const posted: unknown[][] = []; + const spy = jest + .spyOn(window, "postMessage") + .mockImplementation((...args: unknown[]) => { + posted.push(args); + }); + window.dispatchEvent( + new MessageEvent("message", { + data: { + application: "LBE", + type: "lbe:port-request", + scope: "a-different-scope", + }, + source: window, + }) + ); + spy.mockRestore(); + expect(posted).toHaveLength(0); + }); + + test("sends events over the port", async () => { + const { createScopePort } = await loadServer(); + const scope = nextScope(); + const transport = createScopePort(scope); + const port = requestPort(scope); + + const event = nextMessage(port); + transport.sendEvent("accountChanged"); + + await expect(event).resolves.toMatchObject({ + application: "LBE", + event: "accountChanged", + scope, + }); + }); +}); diff --git a/src/extension/content-script/alby.js b/src/extension/content-script/alby.js index d8e8e2ea44..7ac12fc387 100644 --- a/src/extension/content-script/alby.js +++ b/src/extension/content-script/alby.js @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; +import { createScopePort } from "./messagePortServer"; import getOriginData from "./originData"; import shouldInject from "./shouldInject"; @@ -12,90 +13,71 @@ const disabledCalls = ["alby/enable", "alby/isEnabled"]; let isEnabled = false; // store if alby is enabled for this content page let isRejected = false; // store if the alby enable call failed. if so we do not prompt again +// Establish the private channel to the inpage world synchronously at +// document_start, before page scripts can register a competing listener. Only +// request servicing (below) is gated on the async should-inject decision. +const transport = createScopePort("alby"); + async function init() { const inject = await shouldInject(); if (!inject) { return; } - // message listener to listen to inpage alby calls - // those calls get passed on to the background script - // (the inpage script can not do that directly, but only the inpage script can make alby available to the page) - window.addEventListener("message", (ev) => { - // Only accept messages from the current window - if ( - ev.source !== window || - ev.data.application !== "LBE" || - ev.data.scope !== "alby" - ) { + // requests from the inpage alby provider arrive over the private port and get + // passed on to the background script (the inpage script cannot do that + // directly, but only the inpage script can make alby available to the page) + transport.onRequest((data, reply) => { + // if an enable call failed we ignore the request to prevent spamming the user with prompts + if (isRejected) { + reply({ + error: + "window.alby call cancelled (rejecting further window.alby calls until the next reload)", + }); + return; + } + // limit the calls that can be made from window.alby + // only listed calls can be executed + // if not enabled only enable can be called. + const availableCalls = isEnabled ? albyCalls : disabledCalls; + if (!availableCalls.includes(data.action)) { + console.error("Function not available."); return; } - if (ev.data && !ev.data.response) { - // if an enable call railed we ignore the request to prevent spamming the user with prompts - if (isRejected) { - postMessage(ev, { - error: - "window.alby call cancelled (rejecting further window.alby calls until the next reload)", - }); - return; - } - // limit the calls that can be made from window.alby - // only listed calls can be executed - // if not enabled only enable can be called. - const availableCalls = isEnabled ? albyCalls : disabledCalls; - if (!availableCalls.includes(ev.data.action)) { - console.error("Function not available."); - return; - } - - const messageWithOrigin = { - // every call call is scoped in `public` - // this prevents websites from accessing internal actions - action: `public/${ev.data.action}`, - args: ev.data.args, - application: "LBE", - public: true, // indicate that this is a public call from the content script - prompt: true, - origin: getOriginData(), - }; + const messageWithOrigin = { + // every call call is scoped in `public` + // this prevents websites from accessing internal actions + action: `public/${data.action}`, + args: data.args, + application: "LBE", + public: true, // indicate that this is a public call from the content script + prompt: true, + origin: getOriginData(), + }; - const replyFunction = (response) => { - // if it is the enable call we store if alby is enabled for this content script - if (ev.data.action === "alby/enable") { - isEnabled = response.data?.enabled; - if (response.error) { - console.error(response.error); - console.info("Enable was rejected ignoring further alby calls"); - isRejected = true; - } + const replyFunction = (response) => { + // if it is the enable call we store if alby is enabled for this content script + if (data.action === "alby/enable") { + isEnabled = response.data?.enabled; + if (response.error) { + console.error(response.error); + console.info("Enable was rejected ignoring further alby calls"); + isRejected = true; } - if (ev.data.action === "alby/isEnabled") { - isEnabled = response.data?.isEnabled; - } - postMessage(ev, response); - }; - return browser.runtime - .sendMessage(messageWithOrigin) - .then(replyFunction) - .catch(replyFunction); - } + } + if (data.action === "alby/isEnabled") { + isEnabled = response.data?.isEnabled; + } + reply(response); + }; + return browser.runtime + .sendMessage(messageWithOrigin) + .then(replyFunction) + .catch(replyFunction); }); } -function postMessage(ev, response) { - window.postMessage( - { - id: ev.data.id, - application: "LBE", - response: true, - data: response, - scope: "alby", - }, - window.location.origin - ); -} - init(); export {}; diff --git a/src/extension/content-script/liquid.js b/src/extension/content-script/liquid.js index 5fa6ca70da..b54093f8a7 100644 --- a/src/extension/content-script/liquid.js +++ b/src/extension/content-script/liquid.js @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; +import { createScopePort } from "./messagePortServer"; import getOriginData from "./originData"; import shouldInject from "./shouldInject"; @@ -21,6 +22,11 @@ let isRejected = false; // store if the liquid enable call failed. if so we do n const SCOPE = "liquid"; +// Establish the private channel to the inpage world synchronously at +// document_start, before page scripts can register a competing listener. Only +// request servicing (below) is gated on the async should-inject decision. +const transport = createScopePort(SCOPE); + async function init() { const inject = await shouldInject(); if (!inject) { @@ -30,93 +36,66 @@ async function init() { browser.runtime.onMessage.addListener((request, sender, sendResponse) => { // forward account changed messaged to inpage script if (request.action === "accountChanged" && isEnabled) { - window.postMessage( - { action: "accountChanged", scope: "liquid" }, - window.location.origin - ); + transport.sendEvent("accountChanged"); } }); - // message listener to listen to inpage liquid calls - // those calls get passed on to the background script - // (the inpage script can not do that directly, but only the inpage script can make liquid available to the page) - window.addEventListener("message", async (ev) => { - // Only accept messages from the current window - if ( - ev.source !== window || - ev.data.application !== "LBE" || - ev.data.scope !== SCOPE - ) { + // requests from the inpage liquid provider arrive over the private port and + // get passed on to the background script (the inpage script cannot do that + // directly, but only the inpage script can make liquid available to the page) + transport.onRequest(async (data, reply) => { + // if an enable call failed we ignore the request to prevent spamming the user with prompts + if (isRejected) { + console.error( + "Enable had failed. Rejecting further Liquid calls until the next reload" + ); return; } - if (ev.data && !ev.data.response) { - // if an enable call railed we ignore the request to prevent spamming the user with prompts - if (isRejected) { - console.error( - "Enable had failed. Rejecting further Liquid calls until the next reload" - ); - return; - } - - // limit the calls that can be made from window.liquid - // only listed calls can be executed - // if not enabled only enable can be called. - const availableCalls = isEnabled ? liquidCalls : disabledCalls; - if (!availableCalls.includes(ev.data.action)) { - console.error("Function not available."); - return; - } + // limit the calls that can be made from window.liquid + // only listed calls can be executed + // if not enabled only enable can be called. + const availableCalls = isEnabled ? liquidCalls : disabledCalls; + if (!availableCalls.includes(data.action)) { + console.error("Function not available."); + return; + } - const messageWithOrigin = { - // every call call is scoped in `public` - // this prevents websites from accessing internal actions - action: `public/${ev.data.action}`, - args: ev.data.args, - application: "LBE", - public: true, // indicate that this is a public call from the content script - prompt: true, - origin: getOriginData(), - }; - - const replyFunction = (response) => { - if (ev.data.action === `${SCOPE}/enable`) { - isEnabled = response.data?.enabled; - if (response.error) { - console.error(response.error); - console.info("Enable was rejected ignoring further liquid calls"); - isRejected = true; - } + const messageWithOrigin = { + // every call call is scoped in `public` + // this prevents websites from accessing internal actions + action: `public/${data.action}`, + args: data.args, + application: "LBE", + public: true, // indicate that this is a public call from the content script + prompt: true, + origin: getOriginData(), + }; + + const replyFunction = (response) => { + if (data.action === `${SCOPE}/enable`) { + isEnabled = response.data?.enabled; + if (response.error) { + console.error(response.error); + console.info("Enable was rejected ignoring further liquid calls"); + isRejected = true; } + } - if (ev.data.action === `${SCOPE}/isEnabled`) { - isEnabled = response.data?.isEnabled; - } + if (data.action === `${SCOPE}/isEnabled`) { + isEnabled = response.data?.isEnabled; + } - postMessage(ev, response); - }; + reply(response); + }; - return browser.runtime - .sendMessage(messageWithOrigin) - .then(replyFunction) - .catch(replyFunction); - } + return browser.runtime + .sendMessage(messageWithOrigin) + .then(replyFunction) + .catch(replyFunction); }); } init(); -function postMessage(ev, response) { - window.postMessage( - { - id: ev.data.id, - application: "LBE", - response: true, - data: response, - scope: SCOPE, - }, - window.location.origin - ); -} - export {}; diff --git a/src/extension/content-script/messagePortServer.js b/src/extension/content-script/messagePortServer.js new file mode 100644 index 0000000000..ec0f654c6c --- /dev/null +++ b/src/extension/content-script/messagePortServer.js @@ -0,0 +1,98 @@ +// Isolated-world side of the provider message transport. +// +// The inpage providers and these content scripts previously talked to each +// other with `window.postMessage` on the shared page window, where every +// message — request ids and responses alike — was delivered to every other +// message listener in the frame, and any of them could reply first. +// +// Instead we hand the inpage world one end of a `MessageChannel` per scope. +// MessagePort messages go only to the two ports that make up the channel, so a +// script holding neither port is not part of the conversation. +// +// The port is transferred in response to a window message, so it is only +// private from scripts that are not yet running at that moment. +// `createScopePort` therefore does the channel creation and handshake +// synchronously as soon as it is called; the content script calls it at module +// top, ahead of the asynchronous should-inject/blocklist decision that only +// gates whether requests are actually serviced. On MV2 the inpage script is +// injected inline at document_start and this ordering holds; on MV3 the +// main-world script is registered separately and the ordering is not +// guaranteed. Requests that arrive before a handler is registered are buffered; +// if the content script decides not to service this frame they are simply never +// answered (the same effect the old blocklist bail had). +// +// Each content-script bundle runs in the same isolated world but as a separate +// module instance, so every scope creates and owns its own channel here. + +const HANDSHAKE = "LBE"; + +export function createScopePort(scope) { + const channel = new MessageChannel(); + const port = channel.port1; // kept here; port2 is transferred to the inpage world + let transferred = false; + let requestHandler = null; + const buffered = []; + + // Transfer port2 to the inpage world in response to its handshake request. + // We only ever transfer once (a port is neutered after transfer). The inpage + // side keeps asking until it receives the port, which covers either load + // order between the isolated and main worlds. + function handleHandshake(ev) { + if ( + ev.source !== window || + !ev.data || + ev.data.application !== HANDSHAKE || + ev.data.type !== "lbe:port-request" || + ev.data.scope !== scope + ) { + return; + } + if (transferred) return; + transferred = true; + window.postMessage( + { application: HANDSHAKE, type: "lbe:port", scope }, + window.location.origin, + [channel.port2] + ); + } + window.addEventListener("message", handleHandshake); + + function dispatch(data) { + const reply = (response) => { + port.postMessage({ + id: data.id, + application: HANDSHAKE, + response: true, + data: response, + scope, + }); + }; + requestHandler(data, reply); + } + + // Requests from the inpage provider arrive here over the private port. + port.onmessage = (ev) => { + const data = ev.data; + if (!data || data.response) return; + if (!requestHandler) { + buffered.push(data); + return; + } + dispatch(data); + }; + port.start(); + + return { + // register the per-scope request handler; `reply(response)` answers the + // originating call over the same private port. Any requests received before + // this point are flushed now. + onRequest(handler) { + requestHandler = handler; + while (buffered.length) dispatch(buffered.shift()); + }, + // push an event (e.g. accountChanged) to the inpage provider over the port + sendEvent(event) { + port.postMessage({ application: HANDSHAKE, event, scope }); + }, + }; +} diff --git a/src/extension/content-script/nostr.js b/src/extension/content-script/nostr.js index a6ddf235da..c47e0b2762 100644 --- a/src/extension/content-script/nostr.js +++ b/src/extension/content-script/nostr.js @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; +import { createScopePort } from "./messagePortServer"; import getOriginData from "./originData"; import shouldInject from "./shouldInject"; @@ -25,6 +26,11 @@ const disabledCalls = ["nostr/enable", "nostr/isEnabled"]; let isEnabled = false; // store if nostr is enabled for this content page let isRejected = false; // store if the nostr enable call failed. if so we do not prompt again +// Establish the private channel to the inpage world synchronously at +// document_start, before page scripts can register a competing listener. Only +// request servicing (below) is gated on the async should-inject decision. +const transport = createScopePort("nostr"); + async function init() { const inject = await shouldInject(); if (!inject) { @@ -34,100 +40,73 @@ async function init() { browser.runtime.onMessage.addListener((request, sender, sendResponse) => { // forward account changed messaged to inpage script if (request.action === "accountChanged" && isEnabled) { - window.postMessage( - { action: "accountChanged", scope: "nostr" }, - window.location.origin - ); + transport.sendEvent("accountChanged"); } }); - // message listener to listen to inpage nostr calls - // those calls get passed on to the background script - // (the inpage script can not do that directly, but only the inpage script can make nostr available to the page) - window.addEventListener("message", async (ev) => { - // Only accept messages from the current window - if ( - ev.source !== window || - ev.data.application !== "LBE" || - ev.data.scope !== "nostr" - ) { + // requests from the inpage nostr provider arrive over the private port and get + // passed on to the background script (the inpage script cannot do that + // directly, but only the inpage script can make nostr available to the page) + transport.onRequest(async (data, reply) => { + // if an enable call failed we ignore the request to prevent spamming the user with prompts + if (isRejected) { + reply({ + error: + "window.nostr call cancelled (rejecting further window.nostr calls until the next reload)", + }); return; } - if (ev.data && !ev.data.response) { - // if an enable call railed we ignore the request to prevent spamming the user with prompts - if (isRejected) { - postMessage(ev, { - error: - "window.nostr call cancelled (rejecting further window.nostr calls until the next reload)", - }); - return; - } - - // limit the calls that can be made from window.nostr - // only listed calls can be executed - // if not enabled only enable can be called. - const availableCalls = isEnabled ? nostrCalls : disabledCalls; - if (!availableCalls.includes(ev.data.action)) { - console.error("Function not available."); - return; - } + // limit the calls that can be made from window.nostr + // only listed calls can be executed + // if not enabled only enable can be called. + const availableCalls = isEnabled ? nostrCalls : disabledCalls; + if (!availableCalls.includes(data.action)) { + console.error("Function not available."); + return; + } - const messageWithOrigin = { - // every call call is scoped in `public` - // this prevents websites from accessing internal actions - action: `public/${ev.data.action}`, - args: ev.data.args, - application: "LBE", - public: true, // indicate that this is a public call from the content script - prompt: true, - origin: getOriginData(), - }; + const messageWithOrigin = { + // every call call is scoped in `public` + // this prevents websites from accessing internal actions + action: `public/${data.action}`, + args: data.args, + application: "LBE", + public: true, // indicate that this is a public call from the content script + prompt: true, + origin: getOriginData(), + }; - // we don't handle onboard in content script. hence we will be resolving original call nostr/enable with an error hence we need reload the next time we execute the call - const replyFunction = (response) => { - if (ev.data.action === "nostr/enable") { - isEnabled = response.data?.enabled; - if (response.error) { - console.error(response.error); - console.info("User rejected, ignoring further nostr calls"); - isRejected = true; - } - } - if (ev.data.action === "nostr/isEnabled") { - isEnabled = response.data?.isEnabled; + // we don't handle onboard in content script. hence we will be resolving original call nostr/enable with an error hence we need reload the next time we execute the call + const replyFunction = (response) => { + if (data.action === "nostr/enable") { + isEnabled = response.data?.enabled; + if (response.error) { + console.error(response.error); + console.info("User rejected, ignoring further nostr calls"); + isRejected = true; } + } + if (data.action === "nostr/isEnabled") { + isEnabled = response.data?.isEnabled; + } - if (response.denied) { - postMessage(ev, { - error: "permission denied", - }); - } else { - postMessage(ev, response); - } - }; + if (response.denied) { + reply({ + error: "permission denied", + }); + } else { + reply(response); + } + }; - return browser.runtime - .sendMessage(messageWithOrigin) - .then(replyFunction) - .catch(replyFunction); - } + return browser.runtime + .sendMessage(messageWithOrigin) + .then(replyFunction) + .catch(replyFunction); }); } -function postMessage(ev, response) { - window.postMessage( - { - id: ev.data.id, - application: "LBE", - response: true, - data: response, - scope: "nostr", - }, - window.location.origin - ); -} - init(); export {}; diff --git a/src/extension/content-script/webbtc.js b/src/extension/content-script/webbtc.js index 3646c10287..e5ef2918c3 100644 --- a/src/extension/content-script/webbtc.js +++ b/src/extension/content-script/webbtc.js @@ -1,5 +1,6 @@ import browser from "webextension-polyfill"; +import { createScopePort } from "./messagePortServer"; import getOriginData from "./originData"; import shouldInject from "./shouldInject"; // WebBTC calls that can be executed from the WebBTC Provider. @@ -21,6 +22,11 @@ let isRejected = false; // store if the webbtc enable call failed. if so we do n const SCOPE = "webbtc"; +// Establish the private channel to the inpage world synchronously at +// document_start, before page scripts can register a competing listener. Only +// request servicing (below) is gated on the async should-inject decision. +const transport = createScopePort(SCOPE); + async function init() { const inject = await shouldInject(); if (!inject) { @@ -30,93 +36,66 @@ async function init() { browser.runtime.onMessage.addListener((request, sender, sendResponse) => { // forward account changed messaged to inpage script if (request.action === "accountChanged" && isEnabled) { - window.postMessage( - { action: "accountChanged", scope: "webbtc" }, - window.location.origin - ); + transport.sendEvent("accountChanged"); } }); - // message listener to listen to inpage webbtc calls - // those calls get passed on to the background script - // (the inpage script can not do that directly, but only the inpage script can make webln available to the page) - window.addEventListener("message", async (ev) => { - // Only accept messages from the current window - if ( - ev.source !== window || - ev.data.application !== "LBE" || - ev.data.scope !== SCOPE - ) { + // requests from the inpage webbtc provider arrive over the private port and + // get passed on to the background script (the inpage script cannot do that + // directly, but only the inpage script can make webbtc available to the page) + transport.onRequest(async (data, reply) => { + // if an enable call failed we ignore the request to prevent spamming the user with prompts + if (isRejected) { + console.error( + "Enable had failed. Rejecting further WebBTC calls until the next reload" + ); return; } - if (ev.data && !ev.data.response) { - // if an enable call railed we ignore the request to prevent spamming the user with prompts - if (isRejected) { - console.error( - "Enable had failed. Rejecting further WebBTC calls until the next reload" - ); - return; - } - - // limit the calls that can be made from window.webbtc - // only listed calls can be executed - // if not enabled only enable can be called. - const availableCalls = isEnabled ? webbtcCalls : disabledCalls; - if (!availableCalls.includes(ev.data.action)) { - console.error("Function not available."); - return; - } - - const messageWithOrigin = { - // every call call is scoped in `public` - // this prevents websites from accessing internal actions - action: `public/${ev.data.action}`, - args: ev.data.args, - application: "LBE", - public: true, // indicate that this is a public call from the content script - prompt: true, - origin: getOriginData(), - }; + // limit the calls that can be made from window.webbtc + // only listed calls can be executed + // if not enabled only enable can be called. + const availableCalls = isEnabled ? webbtcCalls : disabledCalls; + if (!availableCalls.includes(data.action)) { + console.error("Function not available."); + return; + } - const replyFunction = (response) => { - if (ev.data.action === `${SCOPE}/enable`) { - isEnabled = response.data?.enabled; - if (response.error) { - console.error(response.error); - console.info("Enable was rejected ignoring further webbtc calls"); - isRejected = true; - } + const messageWithOrigin = { + // every call call is scoped in `public` + // this prevents websites from accessing internal actions + action: `public/${data.action}`, + args: data.args, + application: "LBE", + public: true, // indicate that this is a public call from the content script + prompt: true, + origin: getOriginData(), + }; + + const replyFunction = (response) => { + if (data.action === `${SCOPE}/enable`) { + isEnabled = response.data?.enabled; + if (response.error) { + console.error(response.error); + console.info("Enable was rejected ignoring further webbtc calls"); + isRejected = true; } + } - if (ev.data.action === `${SCOPE}/isEnabled`) { - isEnabled = response.data?.isEnabled; - } + if (data.action === `${SCOPE}/isEnabled`) { + isEnabled = response.data?.isEnabled; + } - postMessage(ev, response); - }; + reply(response); + }; - return browser.runtime - .sendMessage(messageWithOrigin) - .then(replyFunction) - .catch(replyFunction); - } + return browser.runtime + .sendMessage(messageWithOrigin) + .then(replyFunction) + .catch(replyFunction); }); } init(); -function postMessage(ev, response) { - window.postMessage( - { - id: ev.data.id, - application: "LBE", - response: true, - data: response, - scope: SCOPE, - }, - window.location.origin - ); -} - export {}; diff --git a/src/extension/content-script/webln.js b/src/extension/content-script/webln.js index 8f38c31b20..54d833e818 100644 --- a/src/extension/content-script/webln.js +++ b/src/extension/content-script/webln.js @@ -1,6 +1,7 @@ import browser from "webextension-polyfill"; import extractLightningData from "./batteries"; +import { createScopePort } from "./messagePortServer"; import getOriginData from "./originData"; import shouldInject from "./shouldInject"; @@ -28,6 +29,11 @@ const disabledCalls = ["webln/enable", "webln/isEnabled"]; let isEnabled = false; // store if webln is enabled for this content page let isRejected = false; // store if the webln enable call failed. if so we do not prompt again +// Establish the private channel to the inpage world synchronously at +// document_start, before page scripts can register a competing listener. Only +// request servicing (below) is gated on the async should-inject decision. +const transport = createScopePort("webln"); + async function init() { const inject = await shouldInject(); if (!inject) { @@ -41,95 +47,68 @@ async function init() { } // forward account changed messaged to inpage script else if (request.action === "accountChanged" && isEnabled) { - window.postMessage( - { action: "accountChanged", scope: "webln" }, - window.location.origin - ); + transport.sendEvent("accountChanged"); } }); - // message listener to listen to inpage webln/webbtc calls - // those calls get passed on to the background script - // (the inpage script can not do that directly, but only the inpage script can make webln available to the page) - window.addEventListener("message", async (ev) => { - // Only accept messages from the current window - if ( - ev.source !== window || - ev.data.application !== "LBE" || - ev.data.scope !== "webln" - ) { + // requests from the inpage webln/webbtc provider arrive over the private port + // and get passed on to the background script (the inpage script cannot do that + // directly, but only the inpage script can make webln available to the page) + transport.onRequest(async (data, reply) => { + // if an enable call failed we ignore the request to prevent spamming the user with prompts + if (isRejected) { + reply({ + error: + "webln.enable() failed (rejecting further window.webln calls until the next reload)", + }); return; } - if (ev.data && !ev.data.response) { - // if an enable call railed we ignore the request to prevent spamming the user with prompts - if (isRejected) { - postMessage(ev, { - error: - "webln.enable() failed (rejecting further window.webln calls until the next reload)", - }); - return; - } - - // limit the calls that can be made from webln - // only listed calls can be executed - // if not enabled only enable can be called. - const availableCalls = isEnabled ? weblnCalls : disabledCalls; - if (!availableCalls.includes(ev.data.action)) { - console.error("Function not available. Is the provider enabled?"); - return; - } + // limit the calls that can be made from webln + // only listed calls can be executed + // if not enabled only enable can be called. + const availableCalls = isEnabled ? weblnCalls : disabledCalls; + if (!availableCalls.includes(data.action)) { + console.error("Function not available. Is the provider enabled?"); + return; + } - const messageWithOrigin = { - // every call call is scoped in `public` - // this prevents websites from accessing internal actions - action: `public/${ev.data.action}`, - args: ev.data.args, - application: "LBE", - public: true, // indicate that this is a public call from the content script - prompt: true, - origin: getOriginData(), - }; + const messageWithOrigin = { + // every call call is scoped in `public` + // this prevents websites from accessing internal actions + action: `public/${data.action}`, + args: data.args, + application: "LBE", + public: true, // indicate that this is a public call from the content script + prompt: true, + origin: getOriginData(), + }; - const replyFunction = (response) => { - // if it is the enable call we store if webln is enabled for this content script - if (ev.data.action === "webln/enable") { - isEnabled = response.data?.enabled; - const enabledEvent = new Event("webln:enabled"); - window.dispatchEvent(enabledEvent); - if (response.error) { - console.error(response.error); - console.info("Enable was rejected ignoring further webln calls"); - isRejected = true; - } + const replyFunction = (response) => { + // if it is the enable call we store if webln is enabled for this content script + if (data.action === "webln/enable") { + isEnabled = response.data?.enabled; + const enabledEvent = new Event("webln:enabled"); + window.dispatchEvent(enabledEvent); + if (response.error) { + console.error(response.error); + console.info("Enable was rejected ignoring further webln calls"); + isRejected = true; } + } - if (ev.data.action === "webln/isEnabled") { - isEnabled = response.data?.isEnabled; - } - postMessage(ev, response); - }; - return browser.runtime - .sendMessage(messageWithOrigin) - .then(replyFunction) - .catch(replyFunction); - } + if (data.action === "webln/isEnabled") { + isEnabled = response.data?.isEnabled; + } + reply(response); + }; + return browser.runtime + .sendMessage(messageWithOrigin) + .then(replyFunction) + .catch(replyFunction); }); } init(); -function postMessage(ev, response) { - window.postMessage( - { - id: ev.data.id, - application: "LBE", - response: true, - data: response, - scope: "webln", - }, - window.location.origin - ); -} - export {}; diff --git a/src/extension/inpage-script/index.js b/src/extension/inpage-script/index.js index 1a19bc4ee7..0b1f468da1 100644 --- a/src/extension/inpage-script/index.js +++ b/src/extension/inpage-script/index.js @@ -4,6 +4,7 @@ import LiquidProvider from "~/extension/providers/liquid"; import NostrProvider from "~/extension/providers/nostr"; import WebBTCProvider from "~/extension/providers/webbtc"; import WebLNProvider from "~/extension/providers/webln"; +import { onScopeEvent } from "~/extension/providers/postMessage"; import shouldInjectInpage from "./shouldInject"; function init() { @@ -19,12 +20,11 @@ function init() { registerLightningLinkClickHandler(); - // Listen for webln events from the extension - // emit events to the websites - window.addEventListener("message", (event) => { - if (event.source === window && event.data.action === "accountChanged") { - eventEmitter(event.data.action, event.data.scope); - } + // Listen for events from the extension (e.g. accountChanged) over each + // provider's private port and emit them to the website. These used to be read + // off the page window, where any script could post them. + ["webln", "nostr", "webbtc", "liquid", "alby"].forEach((scope) => { + onScopeEvent(scope, (action) => eventEmitter(action, scope)); }); } function registerLightningLinkClickHandler() { diff --git a/src/extension/providers/__tests__/postMessage.test.ts b/src/extension/providers/__tests__/postMessage.test.ts new file mode 100644 index 0000000000..4e6f6e8ab9 --- /dev/null +++ b/src/extension/providers/__tests__/postMessage.test.ts @@ -0,0 +1,210 @@ +import { installFakeMessageChannel } from "../../../../tests/unit/helpers/fakeMessageChannel"; + +let restoreMessageChannel: () => void; +beforeAll(() => { + restoreMessageChannel = installFakeMessageChannel(); +}); +afterAll(() => { + restoreMessageChannel(); +}); + +// The inpage side of the provider transport: it asks the isolated world for a +// port, then sends requests over that port and matches replies to callers. + +type PortLike = { + onmessage: ((ev: { data: unknown }) => void) | null; + postMessage: (data: unknown) => void; + start: () => void; +}; + +// Answer the port request the way the content script does: a window message +// carrying one end of a channel. JSDOM's MessageEvent will not take a port in +// its init, so it is attached to the event afterwards. +function serveOnePort(scope: string): Promise { + return new Promise((resolve) => { + const onRequest = (ev: MessageEvent) => { + const data = ev.data as Record; + if ( + !data || + data.application !== "LBE" || + data.type !== "lbe:port-request" || + data.scope !== scope + ) { + return; + } + window.removeEventListener("message", onRequest); + const channel = new MessageChannel(); + const event = new MessageEvent("message", { + data: { application: "LBE", type: "lbe:port", scope }, + source: window, + }); + Object.defineProperty(event, "ports", { value: [channel.port2] }); + window.dispatchEvent(event); + resolve(channel.port1 as unknown as PortLike); + }; + window.addEventListener("message", onRequest); + }); +} + +async function loadTransport() { + let mod!: typeof import("~/extension/providers/postMessage"); + await jest.isolateModulesAsync(async () => { + mod = await import("~/extension/providers/postMessage"); + }); + return mod; +} + +describe("provider message transport", () => { + test("sends a request over the port and resolves with its reply", async () => { + const transport = await loadTransport(); + const served = serveOnePort("webln"); + const result = transport.postMessage("webln", "getInfo", { a: 1 }); + + const port = await served; + port.start(); + const request = await new Promise>((resolve) => { + port.onmessage = (ev) => resolve(ev.data as Record); + }); + + expect(request.action).toBe("webln/getInfo"); + expect(request.scope).toBe("webln"); + expect(request.args).toEqual({ a: 1 }); + + port.postMessage({ + application: "LBE", + response: true, + id: request.id, + scope: "webln", + data: { data: { node: "genuine" } }, + }); + + await expect(result).resolves.toEqual({ node: "genuine" }); + }); + + test("a reply posted on the page window does not resolve the call", async () => { + const transport = await loadTransport(); + const served = serveOnePort("webln"); + const result = transport.postMessage("webln", "getInfo", undefined); + + const port = await served; + port.start(); + const request = await new Promise>((resolve) => { + port.onmessage = (ev) => resolve(ev.data as Record); + }); + + // the shape the old transport accepted, replayed on the shared window with + // the id the request carries + window.postMessage( + { + application: "LBE", + response: true, + id: request.id, + scope: "webln", + data: { data: { node: "substituted" } }, + }, + "*" + ); + + let settled = false; + void result.then(() => (settled = true)); + await new Promise((r) => setTimeout(r, 20)); + expect(settled).toBe(false); + + // the call is still live and the genuine reply still resolves it + port.postMessage({ + application: "LBE", + response: true, + id: request.id, + scope: "webln", + data: { data: { node: "genuine" } }, + }); + await expect(result).resolves.toEqual({ node: "genuine" }); + }); + + test("a second reply for a settled id does not overwrite the result", async () => { + const transport = await loadTransport(); + const consoleError = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); + const served = serveOnePort("webln"); + const result = transport.postMessage("webln", "getInfo", undefined); + + const port = await served; + port.start(); + const request = await new Promise>((resolve) => { + port.onmessage = (ev) => resolve(ev.data as Record); + }); + + const reply = (node: string) => + port.postMessage({ + application: "LBE", + response: true, + id: request.id, + scope: "webln", + data: { data: { node } }, + }); + + reply("genuine"); + reply("second"); + + await expect(result).resolves.toEqual({ node: "genuine" }); + await new Promise((r) => setTimeout(r, 20)); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + test("rejects when the reply carries an error", async () => { + const transport = await loadTransport(); + const served = serveOnePort("webln"); + const result = transport.postMessage("webln", "getInfo", undefined); + + const port = await served; + port.start(); + const request = await new Promise>((resolve) => { + port.onmessage = (ev) => resolve(ev.data as Record); + }); + + port.postMessage({ + application: "LBE", + response: true, + id: request.id, + scope: "webln", + data: { error: "User rejected" }, + }); + + await expect(result).rejects.toThrow("User rejected"); + }); + + test("delivers events pushed over the port to the scope handler", async () => { + const transport = await loadTransport(); + const served = serveOnePort("nostr"); + const events: string[] = []; + transport.onScopeEvent("nostr", (event) => events.push(event)); + + const port = await served; + port.postMessage({ + application: "LBE", + event: "accountChanged", + scope: "nostr", + }); + + await new Promise((r) => setTimeout(r, 20)); + expect(events).toEqual(["accountChanged"]); + }); + + test("an event posted on the page window is not delivered", async () => { + const transport = await loadTransport(); + const served = serveOnePort("nostr"); + const events: string[] = []; + transport.onScopeEvent("nostr", (event) => events.push(event)); + await served; + + window.postMessage( + { application: "LBE", event: "accountChanged", scope: "nostr" }, + "*" + ); + + await new Promise((r) => setTimeout(r, 20)); + expect(events).toEqual([]); + }); +}); diff --git a/src/extension/providers/postMessage.ts b/src/extension/providers/postMessage.ts index 3569a6e79c..543b090a02 100644 --- a/src/extension/providers/postMessage.ts +++ b/src/extension/providers/postMessage.ts @@ -1,7 +1,146 @@ import { PromiseQueue } from "~/extension/providers/promiseQueue"; + // global queue object const queue = new PromiseQueue(); +const HANDSHAKE = "LBE"; + +// One private MessageChannel per scope connects this inpage world to the +// isolated-world content script. Provider traffic travels over that port rather +// than over the shared page window, so it is not delivered to the frame's other +// message listeners. +// +// The handover is the limit of that: it is negotiated with window messages, +// which every listener in the frame receives, so the port is private only from +// scripts that are not yet running when it is transferred. See +// messagePortServer.js for the isolated-world side. +type ScopeEventHandler = (event: string) => void; + +const ports = new Map(); +const portPromises = new Map>(); +const eventHandlers = new Map(); + +// Ask the isolated world for this scope's port and keep asking until it +// arrives. Either world may reach document_start first, so a single request can +// be missed; the retry closes that gap. Once we hold the port, requests stop. +function acquirePort(scope: string): Promise { + const existing = ports.get(scope); + if (existing) return Promise.resolve(existing); + const pending = portPromises.get(scope); + if (pending) return pending; + + const promise = new Promise((resolve, reject) => { + let settled = false; + let attempts = 0; + const MAX_ATTEMPTS = 200; // ~10s at 50ms; covers a slow isolated-world start + + function onMessage(ev: MessageEvent) { + if ( + ev.source !== window || + !ev.data || + ev.data.application !== HANDSHAKE || + ev.data.type !== "lbe:port" || + ev.data.scope !== scope || + !ev.ports[0] + ) { + return; + } + if (settled) return; + settled = true; + window.removeEventListener("message", onMessage); + const port = ev.ports[0]; + port.onmessage = (msg) => handlePortMessage(scope, msg); + port.start(); + ports.set(scope, port); + portPromises.delete(scope); + resolve(port); + } + window.addEventListener("message", onMessage); + + const request = () => { + if (settled) return; + if (attempts++ >= MAX_ATTEMPTS) { + window.removeEventListener("message", onMessage); + portPromises.delete(scope); + reject(new Error("Alby: provider transport unavailable")); + return; + } + window.postMessage( + { application: HANDSHAKE, type: "lbe:port-request", scope }, + window.location.origin + ); + setTimeout(request, 50); + }; + request(); + }); + portPromises.set(scope, promise); + return promise; +} + +// Pending request callbacks, keyed by scope+id, so responses can be routed and +// duplicate/unexpected responses for the same id can be detected. +type Pending = { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; + settled: boolean; +}; +const pending = new Map(); +const noop = () => undefined; +const key = (scope: string, id: string) => `${scope}:${id}`; + +function handlePortMessage(scope: string, msg: MessageEvent) { + const data = msg.data; + if (!data || data.application !== HANDSHAKE) return; + + // events pushed from the extension (e.g. accountChanged) + if (data.event) { + eventHandlers.get(scope)?.(data.event); + return; + } + + if (!data.response) return; + const p = pending.get(key(scope, data.id)); + if (!p) return; + + // Only the isolated-world content script holds the other end of this port, so + // a second response for an id we have already settled is never expected. Do + // not silently trust it — surface it instead of overwriting the result. + if (p.settled) { + console.error( + "Alby: ignoring unexpected duplicate provider response", + scope, + data.id + ); + return; + } + // The entry stays behind, marked settled, so that a later reply for the same + // id is recognised as unexpected instead of being taken for an unknown id. + // The callbacks are dropped so the caller's closures are not retained. + p.settled = true; + const { resolve, reject } = p; + p.resolve = noop; + p.reject = noop; + + if (data.data?.error) { + reject(new Error(data.data.error)); + } else { + // data.data is the background response; data.data.data is the payload + resolve(data.data?.data); + } +} + +export function onScopeEvent(scope: string, handler: ScopeEventHandler): void { + eventHandlers.set(scope, handler); + // Kick the handshake now so the port is transferred at document_start (before + // page scripts can intercept it) and events can be delivered before the first + // call. If no content script services this frame (e.g. a blocklisted page) + // the handshake never completes and the promise rejects after its retry cap; + // swallow that here since there is nothing to deliver anyway. + acquirePort(scope).catch(() => { + /* no content-script transport in this frame */ + }); +} + export function postMessage( scope: string, action: string, @@ -12,48 +151,25 @@ export function postMessage( new Promise((resolve, reject) => { const id = Math.random().toString().slice(4); - // post the request to the content script. from there it gets passed to the background script and back - // in page script can not directly connect to the background script - window.postMessage( - { - id: id, - application: "LBE", - prompt: true, - action: `${scope}/${action}`, - scope: scope, - args, - }, - window.location.origin - ); - - function handleWindowMessage(messageEvent: MessageEvent) { - // check if it is a relevant message - // there are some other events happening - if ( - messageEvent.origin !== window.location.origin || - !messageEvent.data || - !messageEvent.data.response || - messageEvent.data.application !== "LBE" || - messageEvent.data.scope !== scope || - messageEvent.data.id !== id - ) { - return; - } - - if (messageEvent.data.data.error) { - reject(new Error(messageEvent.data.data.error)); - } else { - // 1. data: the message data - // 2. data: the data passed as data to the message - // 3. data: the actual response data - resolve(messageEvent.data.data.data); - } - - // For some reason must happen only at the end of this function - window.removeEventListener("message", handleWindowMessage); - } - - window.addEventListener("message", handleWindowMessage); + acquirePort(scope) + .then((port) => { + pending.set(key(scope, id), { + resolve: resolve as (value: unknown) => void, + reject, + settled: false, + }); + // sent over the private port; the isolated content script forwards + // it to the background script and replies on the same port + port.postMessage({ + id, + application: HANDSHAKE, + prompt: true, + action: `${scope}/${action}`, + scope, + args, + }); + }) + .catch(reject); }) ); } diff --git a/tests/unit/helpers/fakeMessageChannel.ts b/tests/unit/helpers/fakeMessageChannel.ts new file mode 100644 index 0000000000..8c147a8973 --- /dev/null +++ b/tests/unit/helpers/fakeMessageChannel.ts @@ -0,0 +1,66 @@ +// JSDOM ships no MessageChannel, which the provider transport is built on. +// A minimal stand-in: two entangled ports that deliver to each other only, with +// messages buffered until start() as the real thing does. +// +// It is installed per test file rather than globally: msw relies on the real +// MessageChannel and misbehaves when the global is replaced for every suite. + +type Listener = ((ev: { data: unknown }) => void) | null; + +class FakeMessagePort { + onmessage: Listener = null; + _other: FakeMessagePort | null = null; + _started = false; + _closed = false; + _buffered: unknown[] = []; + + start() { + if (this._started) return; + this._started = true; + while (this._buffered.length) this._deliver(this._buffered.shift()); + } + + close() { + this._closed = true; + } + + postMessage(data: unknown) { + const other = this._other; + if (!other || other._closed) return; + // queued rather than delivered synchronously, as a real port does + Promise.resolve().then(() => other._receive(data)); + } + + _receive(data: unknown) { + if (!this._started) { + this._buffered.push(data); + return; + } + this._deliver(data); + } + + _deliver(data: unknown) { + if (this.onmessage) this.onmessage({ data }); + } +} + +class FakeMessageChannel { + port1: FakeMessagePort; + port2: FakeMessagePort; + constructor() { + this.port1 = new FakeMessagePort(); + this.port2 = new FakeMessagePort(); + this.port1._other = this.port2; + this.port2._other = this.port1; + } +} + +// Installs the stand-in for the current test file and returns a restore +// function that puts back whatever was there before. +export function installFakeMessageChannel() { + const previous = (global as Record).MessageChannel; + (global as Record).MessageChannel = FakeMessageChannel; + return () => { + (global as Record).MessageChannel = previous; + }; +}