feat: vendor and censor the phishing blocklist at build time (closes #219)
This commit was merged in pull request #301.
This commit is contained in:
@@ -13,7 +13,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const { chromium } = require("playwright-core");
|
||||
const { installNetworkStubs } = require("./network");
|
||||
const { installNetworkStubs, WORKER_PROBE_URL } = require("./network");
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
||||
@@ -129,42 +129,109 @@ function attachErrorListeners(ctx, errors) {
|
||||
// if it ever stops being.
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// The most recently seen background worker, waiting for one if none has
|
||||
// appeared yet. Most recent rather than first: Chrome stops an idle MV3
|
||||
// worker and starts a fresh one on the next event, and a handle to a
|
||||
// stopped worker cannot be evaluated in.
|
||||
async function serviceWorker(ctx) {
|
||||
const [existing] = ctx.serviceWorkers();
|
||||
if (existing) return existing;
|
||||
const workers = ctx.serviceWorkers();
|
||||
const latest = workers[workers.length - 1];
|
||||
if (latest) return latest;
|
||||
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
||||
}
|
||||
|
||||
// How long to wait for the background worker's first outbound request.
|
||||
//
|
||||
// The margin that actually decides whether this check is sound is not
|
||||
// this timeout — it is whether the route handler is installed before the
|
||||
// worker fetches. Measured over several runs: route installation
|
||||
// completes 11-23ms after the context comes up, and the worker's
|
||||
// blocklist fetch arrives 525-883ms after that, so the route wins by
|
||||
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
|
||||
// top of that; losing the race fails the run rather than passing it
|
||||
// quietly, which was verified by forcing a 3s delay before route
|
||||
// installation.
|
||||
// How long to wait for the probe request the worker is asked to make.
|
||||
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
||||
|
||||
// ctx.route() only sees service-worker requests when Playwright runs with
|
||||
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
||||
// sets. Without it the worker's traffic — notably the phishing blocklist
|
||||
// fetch src/background/index.js issues at startup — goes to the real
|
||||
// internet, and nothing says so, because src/shared/phishingDomains.js
|
||||
// swallows fetch failures. A harness whose isolation can lapse in silence
|
||||
// is worthless, so this does not take the flag on trust: the background
|
||||
// worker's own startup fetch has to show up in the route handler, or the
|
||||
// suite refuses to run.
|
||||
// sets. Without it every fetch the background worker makes goes to the
|
||||
// real internet and nothing says so. A harness whose isolation can lapse
|
||||
// in silence is worthless, so this does not take the flag on trust: a
|
||||
// request the worker itself issues has to show up in the route handler,
|
||||
// or the suite refuses to run.
|
||||
//
|
||||
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
|
||||
// evaluating in an extension worker this early kills it (the call fails
|
||||
// with "Target page, context or browser has been closed" and the worker
|
||||
// disappears), which would break the very thing being measured. Observing
|
||||
// traffic the extension already generates costs nothing and cannot
|
||||
// perturb it.
|
||||
async function assertWorkerTrafficIntercepted(stubs) {
|
||||
// The anchor is a probe the harness asks the worker for, not traffic the
|
||||
// extension generates on its own. It used to be the phishing blocklist
|
||||
// fetch src/background/index.js issued at startup; that fetch is gone —
|
||||
// the blocklist is vendored at build time and the extension contacts
|
||||
// nobody when it starts — so there is no longer any startup traffic to
|
||||
// observe and the check generates its own.
|
||||
//
|
||||
// Evaluating in the worker straight after launch does not work, and that
|
||||
// is not a stale observation: it was tried again here and failed with
|
||||
// "Target page, context or browser has been closed" on the first run.
|
||||
// Chrome stops the freshly registered worker as soon as it has nothing to
|
||||
// do, and the extension no longer gives it anything to do — which is the
|
||||
// same change that removed the old anchor. So the probe wakes the worker
|
||||
// before it evaluates in it, by sending it a message from an extension
|
||||
// page and waiting for the reply: delivering a message is what starts a
|
||||
// stopped worker, and a worker that has just answered one is alive.
|
||||
// The evaluated fetch is not awaited, so nothing in the worker is held
|
||||
// open by the probe either.
|
||||
async function wakeWorker(ctx) {
|
||||
const sw = await serviceWorker(ctx);
|
||||
const extensionId = new URL(sw.url()).host;
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(
|
||||
"chrome-extension://" + extensionId + "/src/popup/index.html",
|
||||
);
|
||||
// eth_chainId is answered from local state: it wakes the worker
|
||||
// and changes nothing.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
type: "AUTISTMASK_RPC",
|
||||
method: "eth_chainId",
|
||||
params: [],
|
||||
},
|
||||
() => resolve(null),
|
||||
);
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function probeFromWorker(ctx, url) {
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
await wakeWorker(ctx);
|
||||
const sw = await serviceWorker(ctx);
|
||||
await sw.evaluate((u) => {
|
||||
// Deliberately not awaited and never rejected: what is
|
||||
// being observed is that the request reaches the route
|
||||
// handler, and an unhandled rejection in the worker would
|
||||
// be collected as a suite error if it did not.
|
||||
fetch(u).catch(() => {});
|
||||
}, url);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"could not ask the background worker to fetch " +
|
||||
url +
|
||||
", so service-worker interception was never tested. Last " +
|
||||
"error: " +
|
||||
(lastError && lastError.message),
|
||||
);
|
||||
}
|
||||
|
||||
async function assertWorkerTrafficIntercepted(ctx, stubs) {
|
||||
await probeFromWorker(ctx, WORKER_PROBE_URL);
|
||||
|
||||
const seen = await stubs.waitForServiceWorkerTraffic(
|
||||
WORKER_TRAFFIC_TIMEOUT_MS,
|
||||
);
|
||||
@@ -177,19 +244,16 @@ async function assertWorkerTrafficIntercepted(stubs) {
|
||||
throw new Error(
|
||||
"observed no service-worker request in the route handler within " +
|
||||
WORKER_TRAFFIC_TIMEOUT_MS +
|
||||
"ms. Under working interception the background worker's " +
|
||||
"startup blocklist fetch (src/background/index.js) reaches the " +
|
||||
"handler about half a second after the route is installed. " +
|
||||
"Two causes are plausible and this check cannot distinguish " +
|
||||
"ms, although the background worker was asked to fetch " +
|
||||
WORKER_PROBE_URL +
|
||||
". Two causes are plausible and this check cannot distinguish " +
|
||||
"them: (1) service-worker interception is not in effect, so " +
|
||||
"that traffic went to the real internet unobserved — the suite " +
|
||||
"that request went to the real internet unobserved — the suite " +
|
||||
"must be run through script/test-e2e, which sets " +
|
||||
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
||||
"Playwright upgrade may have dropped or renamed that flag; " +
|
||||
"(2) no worker request was made in the first place — the route " +
|
||||
"lost the startup race, or the worker no longer fetches at " +
|
||||
"startup, in which case this check needs a new anchor because " +
|
||||
"there is no longer any worker traffic to observe. Either way " +
|
||||
"(2) the probe never ran, because the worker was torn down " +
|
||||
"between being handed over and being evaluated in. Either way " +
|
||||
"the fix is a replacement mechanism or an honest downgrade of " +
|
||||
"the isolation claims in tests/e2e/network.js and README.md — " +
|
||||
"not deleting this check",
|
||||
@@ -241,7 +305,7 @@ async function launch(routeOpts) {
|
||||
routeOpts.report = (text) => errors.record("network", text);
|
||||
const stubs = await installNetworkStubs(ctx, routeOpts);
|
||||
|
||||
await assertWorkerTrafficIntercepted(stubs);
|
||||
await assertWorkerTrafficIntercepted(ctx, stubs);
|
||||
|
||||
// The extension id is derived from the unpacked path, so it
|
||||
// changes and must never be hardcoded. It is the host part of the
|
||||
|
||||
Reference in New Issue
Block a user