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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 182 additions & 0 deletions src/extension/content-script/__tests__/messagePortServer.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)?.type === "lbe:port" &&
(args[0] as Record<string, string>)?.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<Record<string, unknown>> {
return new Promise((resolve) => {
port.onmessage = (ev) => resolve(ev.data as Record<string, unknown>);
});
}

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<string, unknown>, 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<string, unknown>, 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,
});
});
});
122 changes: 52 additions & 70 deletions src/extension/content-script/alby.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import browser from "webextension-polyfill";

import { createScopePort } from "./messagePortServer";
import getOriginData from "./originData";
import shouldInject from "./shouldInject";

Expand All @@ -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 {};
Loading
Loading