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