aboutsummaryrefslogtreecommitdiffstats
path: root/tools/puppeteer.js
blob: 2609981932cdadb2bc05e9cf0abe22bc8e73ee58 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// puppeteer-core for the browser tests, with one difference: a page's
// sockets to public relays never leave the machine. Throwaway test wallets
// boot, subscribe and publish like real ones, and a suite run from one home
// address gets that address banned (nos.lol, relay.damus.io, 2026-10-07).
// So every socket to a host that isn't local is answered in the page by a
// stand-in relay that has nothing stored: EVENT gets OK, REQ gets EOSE,
// COUNT gets 0. Local relays (ws://127.0.0.1:…, a test's own) are untouched,
// and a test's own WebSocket stubs still see every send, as before.
//
// A test that reads real content off the public relays launches with
// { relays: 'read' }: its sockets are real, but nothing it publishes leaves
// the page (each EVENT is answered OK in place). LIVE_RELAYS=read does the
// same for any test. { relays: 'live' } (a test of a public service, like
// the Cordn coordinator) or LIVE_RELAYS=1 turns the guard off, and
// LIVE_RELAYS=relay.example.com,other.example lets just those hosts through.
// puppeteer.connect (driving a real Chrome) is never guarded.
//
// Use: import puppeteer from './puppeteer.js'; (in place of 'puppeteer-core')
import real from 'puppeteer-core';

const live = (process.env.LIVE_RELAYS || '').trim();
const allow = live && live !== '1' && live !== 'read' ? live.split(',').map((h) => h.trim()).filter(Boolean) : [];

// Runs in the page before anything else.
function relayGuard(allow, readOnly) {
  if (window.__relayGuard) return;
  window.__relayGuard = { blocked: [], sent: 0, events: [] }; // events: what didn't go out
  const Real = window.WebSocket;
  const nativeSend = Real.prototype.send;
  const nativeClose = Real.prototype.close;
  const fakes = new WeakSet();
  const reading = new WeakSet(); // real public sockets in read mode
  const local = (h) => h === 'localhost' || h === '[::1]' || h === '0.0.0.0' || /^127\./.test(h) || h.endsWith('.localhost');
  const answer = (ws, msg) => setTimeout(() => {
    if (ws.readyState !== 1) return;
    ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(msg) }));
  }, 5);
  const relay = (ws, d) => {
    let m;
    try { m = JSON.parse(d); } catch { return; }
    if (!Array.isArray(m)) return;
    window.__relayGuard.sent++;
    if (m[0] === 'EVENT' && m[1]?.id) { window.__relayGuard.events.push(m[1]); answer(ws, ['OK', m[1].id, true, '']); }
    else if (m[0] === 'REQ') answer(ws, ['EOSE', m[1]]);
    else if (m[0] === 'COUNT') answer(ws, ['COUNT', m[1], { count: 0 }]);
    else if (m[0] === 'AUTH' && m[1]?.id) answer(ws, ['OK', m[1].id, true, '']);
  };
  // Every send goes through here, so a test that wraps
  // WebSocket.prototype.send (before or after) still sees a stand-in's.
  Real.prototype.send = function (d) {
    if (fakes.has(this)) { if (this.readyState !== 1) throw new DOMException('not open', 'InvalidStateError'); return relay(this, d); }
    if (reading.has(this) && typeof d === 'string' && /^\s*\[\s*"EVENT"/.test(d)) return relay(this, d);
    return nativeSend.call(this, d);
  };
  Real.prototype.close = function (code, reason) {
    if (!fakes.has(this)) return nativeClose.call(this, code, reason);
    if (this.readyState >= 2) return;
    this.readyState = 2;
    setTimeout(() => { this.readyState = 3; this.dispatchEvent(new CloseEvent('close', { code: code || 1000, reason: reason || '', wasClean: true })); }, 0);
  };
  const handlerProp = (ws, type) => {
    let fn = null;
    const call = (e) => fn && fn.call(ws, e);
    ws.addEventListener(type, call);
    Object.defineProperty(ws, 'on' + type, { get: () => fn, set: (v) => { fn = typeof v === 'function' ? v : null; }, configurable: true });
  };
  function WebSocket(url, protocols) {
    if (!new.target) throw new TypeError("Failed to construct 'WebSocket': Please use the 'new' operator");
    let u;
    try { u = new URL(url, location.href); } catch { return new Real(url, protocols); }
    if (u.protocol !== 'wss:' && u.protocol !== 'ws:') return new Real(url, protocols);
    if (local(u.hostname) || allow.includes(u.hostname)) return new Real(url, protocols);
    if (readOnly) { const ws = new Real(url, protocols); reading.add(ws); return ws; }
    window.__relayGuard.blocked.push(u.href);
    // a genuine EventTarget (so dispatchEvent works) that passes instanceof
    const ws = Object.setPrototypeOf(new EventTarget(), Real.prototype);
    fakes.add(ws);
    Object.defineProperties(ws, {
      url: { value: u.href }, protocol: { value: '' }, extensions: { value: '' },
      bufferedAmount: { value: 0 }, binaryType: { value: 'blob', writable: true },
      readyState: { value: 0, writable: true },
    });
    for (const t of ['open', 'message', 'close', 'error']) handlerProp(ws, t);
    setTimeout(() => { if (ws.readyState !== 0) return; ws.readyState = 1; ws.dispatchEvent(new Event('open')); }, 0);
    return ws;
  }
  WebSocket.prototype = Real.prototype;
  for (const k of ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']) WebSocket[k] = Real[k];
  window.WebSocket = WebSocket;
}

const guarded = new WeakSet();
const guardPage = async (page, ro) => {
  if (!page || guarded.has(page)) return;
  guarded.add(page);
  await page.evaluateOnNewDocument(relayGuard, allow, ro).catch(() => {});
};
const guardContext = (ctx, ro) => {
  const newPage = ctx.newPage.bind(ctx);
  ctx.newPage = async (...a) => { const p = await newPage(...a); await guardPage(p, ro); return p; };
};
const guardBrowser = async (browser, ro) => {
  guardContext(browser, ro);
  for (const name of ['createBrowserContext', 'createIncognitoBrowserContext']) {
    if (typeof browser[name] !== 'function') continue;
    const make = browser[name].bind(browser);
    browser[name] = async (...a) => { const c = await make(...a); guardContext(c, ro); return c; };
  }
  // pages it opened itself (popups, the first tab)
  browser.on('targetcreated', async (t) => { if (t.type() === 'page') await guardPage(await t.page().catch(() => null), ro); });
  for (const p of await browser.pages()) await guardPage(p, ro);
};

const launch = async ({ relays, ...opts } = {}) => {
  // a machine without /usr/bin/google-chrome names its browser: CHROME=/path/to/chrome
  if (process.env.CHROME) opts.executablePath = process.env.CHROME;
  const browser = await real.launch(opts);
  if (live !== '1' && relays !== 'live') await guardBrowser(browser, live === 'read' || relays === 'read');
  return browser;
};

export default new Proxy(real, {
  get(t, k) {
    if (k === 'launch') return launch;
    const v = t[k];
    return typeof v === 'function' ? v.bind(t) : v;
  },
});