test-firefox-sender-context.mjs

10.4 KB · raw

1#!/usr/bin/env node23import { execFile } from "node:child_process";4import { promisify } from "node:util";5import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";6import { existsSync } from "node:fs";7import { tmpdir } from "node:os";8import { join, resolve } from "node:path";9import net from "node:net";10import { start } from "geckodriver";1112const execFileAsync = promisify(execFile);13const ROOT = resolve(new URL("..", import.meta.url).pathname);14const DIST = join(ROOT, "dist-firefox");15const PROBE = join(ROOT, "tests/fixtures/firefox-sender-probe.js");16const WEB_EXT = join(ROOT, "node_modules/.bin/web-ext");17const EXTENSION_ID = "[email protected]";1819function findFirefoxBinary() {20  const candidates = [21    process.env.PEACH_FIREFOX_BINARY,22    process.env.FIREFOX_BIN,23    "/usr/bin/firefox",24    "/usr/local/bin/firefox",25    "/Applications/Firefox.app/Contents/MacOS/firefox",26    "/Applications/Firefox Nightly.app/Contents/MacOS/firefox",27    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1538/firefox/Nightly.app/Contents/MacOS/firefox"),28    join(process.env.HOME ?? "", "Library/Caches/ms-playwright/firefox-1511/firefox/Nightly.app/Contents/MacOS/firefox"),29  ].filter(Boolean);30  return candidates.find((candidate) => existsSync(candidate));31}3233function findFreePort() {34  return new Promise((resolvePort, reject) => {35    const server = net.createServer();36    server.once("error", reject);37    server.listen(0, "127.0.0.1", () => {38      const address = server.address();39      if (!address || typeof address === "string") {40        server.close();41        reject(new Error("Could not allocate a WebDriver port"));42        return;43      }44      server.close(() => resolvePort(address.port));45    });46  });47}4849async function waitForDriver(port, driver) {50  const deadline = Date.now() + 30_000;51  while (Date.now() < deadline) {52    try {53      const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, {}, 500);54      if (response.ok && (await response.json()).value?.ready) return;55    } catch {56      if (driver.exitCode !== null || driver.signalCode !== null) {57        throw new Error(`geckodriver exited during startup (${driver.exitCode ?? driver.signalCode})`);58      }59    }60    await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));61  }62  throw new Error("geckodriver did not become ready within 30 seconds");63}6465async function fetchWithTimeout(url, options, timeoutMs) {66  const controller = new AbortController();67  let timeoutId;68  const timeout = new Promise((_, reject) => {69    timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);70  });71  try {72    return await Promise.race([73      fetch(url, { ...options, signal: controller.signal }),74      timeout,75    ]);76  } catch (error) {77    controller.abort();78    throw error;79  } finally {80    clearTimeout(timeoutId);81  }82}8384async function webdriverRequest(port, path, options = {}) {85  const response = await fetchWithTimeout(`http://127.0.0.1:${port}${path}`, {86    ...options,87    headers: { "content-type": "application/json", ...(options.headers ?? {}) },88  }, 10_000);89  const body = await response.json();90  if (!response.ok || body.value?.error) {91    throw new Error(`WebDriver ${options.method ?? "GET"} ${path} failed: ${JSON.stringify(body)}`);92  }93  return body.value;94}9596async function buildTestExtension(tempRoot) {97  const sourceDir = join(tempRoot, "extension");98  const artifactsDir = join(tempRoot, "artifacts");99  await cp(DIST, sourceDir, { recursive: true });100  await mkdir(artifactsDir, { recursive: true });101  await cp(PROBE, join(sourceDir, "firefox-sender-probe.js"));102103  const manifestPath = join(sourceDir, "manifest.json");104  const manifest = JSON.parse(await readFile(manifestPath, "utf8"));105  manifest.content_scripts.push({106    matches: ["<all_urls>"],107    js: ["firefox-sender-probe.js"],108    run_at: "document_start",109    all_frames: false,110  });111  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);112113  await execFileAsync(WEB_EXT, [114    "build",115    "--source-dir", sourceDir,116    "--artifacts-dir", artifactsDir,117    "--filename", "peach-firefox-sender-context.xpi",118    "--overwrite-dest",119    "--no-input",120  ], { cwd: ROOT });121  return join(artifactsDir, "peach-firefox-sender-context.xpi");122}123124async function run() {125  if (!existsSync(DIST)) throw new Error("dist-firefox is missing; run npm run build:firefox first");126  if (!existsSync(WEB_EXT)) throw new Error("web-ext is missing; run bun install first");127128  const tempRoot = await mkdtemp(join(tmpdir(), "peach-firefox-sender-"));129  let driver;130  let driverPort;131  let sessionId;132  try {133    const xpiPath = await buildTestExtension(tempRoot);134    const firefoxBinary = findFirefoxBinary();135    // Match the Playwright Firefox release environment: this host cannot map136    // the hardware compositor, and Marionette must inherit the same fallback.137    Object.assign(process.env, {138      MOZ_HEADLESS: "1",139      MOZ_DISABLE_CONTENT_SANDBOX: "1",140      MOZ_DISABLE_RDD_SANDBOX: "1",141      LIBGL_ALWAYS_SOFTWARE: "1",142    });143    const port = await findFreePort();144    driverPort = port;145    driver = await start({146      host: "127.0.0.1",147      port,148      geckoDriverVersion: "0.37.1",149      ...(firefoxBinary ? { binary: firefoxBinary } : {}),150      log: "error",151      spawnOpts: {152        env: {153          ...process.env,154          MOZ_HEADLESS: "1",155          MOZ_DISABLE_CONTENT_SANDBOX: "1",156          MOZ_DISABLE_RDD_SANDBOX: "1",157          LIBGL_ALWAYS_SOFTWARE: "1",158        },159      },160    });161    await waitForDriver(port, driver);162163    const capabilities = {164      browserName: "firefox",165      acceptInsecureCerts: true,166      "moz:firefoxOptions": {167        args: ["-headless", "-no-remote"],168        ...(firefoxBinary ? { binary: firefoxBinary } : {}),169        prefs: {170          "browser.shell.checkDefaultBrowser": false,171          "browser.startup.page": 0,172          "browser.startup.homepage": "about:blank",173          "security.sandbox.content.level": 0,174          "security.sandbox.gpu.level": 0,175          "security.sandbox.rdd.level": 0,176          "gfx.webrender.all": false,177          "gfx.webrender.software": false,178          "layers.acceleration.disabled": true,179          "media.hardware-video-decoding.force-enabled": false,180        },181      },182    };183    const session = await webdriverRequest(port, "/session", {184      method: "POST",185      body: JSON.stringify({ capabilities: { alwaysMatch: capabilities } }),186    });187    sessionId = session.sessionId;188189    const install = await webdriverRequest(port, `/session/${sessionId}/moz/addon/install`, {190      method: "POST",191      body: JSON.stringify({192        addon: (await readFile(xpiPath)).toString("base64"),193        temporary: true,194      }),195    });196    if (install?.id && install.id !== EXTENSION_ID) {197      throw new Error(`Unexpected Firefox add-on id: ${install.id}`);198    }199    await webdriverRequest(port, `/session/${sessionId}/url`, {200      method: "POST",201      body: JSON.stringify({ url: "https://example.com/" }),202    });203    // Firefox may finish navigation before document_start content scripts have204    // registered their relay listener; wait for the extension boundary to settle.205    await new Promise((resolveDelay) => setTimeout(resolveDelay, 2000));206    const result = await webdriverRequest(port, `/session/${sessionId}/execute/async`, {207      method: "POST",208      body: JSON.stringify({209        script: `210          const done = arguments[arguments.length - 1];211          const source = "peach-firefox-sender-probe";212          const send = (message) => new Promise((resolve) => {213            const id = crypto.randomUUID();214            const timer = setTimeout(() => {215              window.removeEventListener("message", onResponse);216              resolve({ ok: false, error: "probe_timeout" });217            }, 5000);218            function onResponse(event) {219              let response;220              try {221                response = JSON.parse(event.data);222              } catch {223                return;224              }225              if (response?.source !== source + ":response" || response.id !== id) return;226              clearTimeout(timer);227              window.removeEventListener("message", onResponse);228              resolve(response.response);229            }230            window.addEventListener("message", onResponse);231            window.postMessage(JSON.stringify({ source, id, message }), "*");232          });233          Promise.all([234            send({ type: "GET_SETTINGS" }),235            send({ type: "GET_ENTRIES" }),236            send({ type: "GET_RECOVERY_WORDS" }),237            send({ type: "STORAGE_SESSION" }),238          ]).then(([allowed, entries, recovery, storage]) => done({239            pageOrigin: location.origin,240            probeReady: document.documentElement.dataset.peachFirefoxSenderProbe ?? null,241            allowed,242            entries,243            recovery,244            storage,245          }));246        `,247        args: [],248      }),249    });250251    if (result.pageOrigin !== "https://example.com") {252      throw new Error(`Unexpected page origin: ${result.pageOrigin}`);253    }254    if (result.probeReady !== "ready") {255      throw new Error(`Firefox content-script probe did not execute: ${JSON.stringify(result)}`);256    }257    if (Object.prototype.hasOwnProperty.call(result.storage ?? {}, "peach_session_dk")) {258      throw new Error(`Firefox content-script accessed the session escrow key: ${JSON.stringify(result.storage)}`);259    }260    if (result.allowed?.error === "invalid_sender") {261      throw new Error("Content-script-allowed GET_SETTINGS was rejected");262    }263    for (const [name, response] of [["GET_ENTRIES", result.entries], ["GET_RECOVERY_WORDS", result.recovery]]) {264      if (response?.error !== "invalid_sender") {265        throw new Error(`${name} was not rejected from the real Firefox content-script sender: ${JSON.stringify({ result, response })}`);266      }267    }268    console.log("Firefox sender-context test passed: real content-script GET_ENTRIES and GET_RECOVERY_WORDS were rejected, GET_SETTINGS remained allowed.");269  } finally {270    if (driver && sessionId) {271      try {272        await webdriverRequest(driverPort, `/session/${sessionId}`, { method: "DELETE" });273      } catch {274        // The browser may already have exited after a failed startup.275      }276    }277    driver?.kill("SIGTERM");278    await rm(tempRoot, { recursive: true, force: true });279  }280}281282run().catch((error) => {283  console.error(error instanceof Error ? error.message : error);284  process.exitCode = 1;285});