@open-mercato/web-research-browser 0.9.1-develop.7548.1.ee60458c45 → 0.9.1-develop.7553.1.8dbab05b5a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/playwright.ts"],
4
- "sourcesContent": ["import { spawn } from 'node:child_process'\nimport { assertPublicUrl } from '@open-mercato/web-research'\nimport { SidecarError } from './protocol'\n\n/**\n * Structural projections of the Playwright surface we touch.\n *\n * Playwright is a real dependency of this package \u2014 installing the adapter gets\n * you the tooling it needs \u2014 but it is still reached through a dynamic import\n * inside the sidecar, so the app and MCP processes never load it. The types stay\n * structural for the same reason: nothing here should pull the SDK into a bundle\n * that only ever spawns the child.\n */\nexport type PageHandle = {\n goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>\n content(): Promise<string>\n url(): string\n close(): Promise<void>\n}\n\nexport type ContextHandle = {\n newPage(): Promise<unknown>\n close(): Promise<void>\n route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>\n}\n\nexport type RouteHandle = {\n request(): { url(): string; isNavigationRequest(): boolean }\n abort(reason?: string): Promise<void>\n continue(): Promise<void>\n}\n\nexport type BrowserHandle = {\n newContext(options?: Record<string, unknown>): Promise<ContextHandle>\n close(): Promise<void>\n}\n\nexport type BrowserType = {\n launch(options?: { headless?: boolean; args?: string[] }): Promise<BrowserHandle>\n}\n\nfunction isModuleNotFound(message: string): boolean {\n return (\n /cannot find (package|module) ['\"]?playwright/i.test(message) ||\n /ERR_MODULE_NOT_FOUND/i.test(message) ||\n /failed to resolve ['\"]?playwright/i.test(message)\n )\n}\n\nexport async function importChromium(): Promise<BrowserType> {\n try {\n const playwright = (await import('playwright')) as unknown as { chromium: BrowserType }\n return playwright.chromium\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n // Playwright is a dependency of this package, so a module-not-found here\n // means a broken install (hoisting, a pruned image layer), not a missing opt-in.\n throw new SidecarError(\n `Playwright could not be loaded even though it is a dependency of this package \u2014 reinstall dependencies. Underlying: ${message}`,\n isModuleNotFound(message) ? 'needs-install' : 'init',\n )\n }\n}\n\n/**\n * Playwright's launch error when the npm package is installed but the browser\n * binary was never downloaded. The wording has been stable across 1.x, and it is\n * the only signal Playwright gives for this case.\n */\nfunction isMissingBrowserError(error: unknown): boolean {\n return error instanceof Error && /Executable doesn'?t exist at/i.test(error.message)\n}\n\n/** Generous, but bounded \u2014 an unbounded install hangs the sidecar forever. */\nconst INSTALL_TIMEOUT_MS = 10 * 60_000\n\nexport type InstallRunner = (onProgress: (line: string) => void) => Promise<void>\n\nconst defaultInstallRunner: InstallRunner = async (onProgress) =>\n new Promise((resolve, reject) => {\n const child = spawn('npx', ['playwright', 'install', 'chromium'], { stdio: ['ignore', 'pipe', 'pipe'] })\n let tail = ''\n const collect = (chunk: Buffer): void => {\n tail = `${tail}${chunk.toString('utf8')}`.slice(-4000)\n }\n child.stdout?.on('data', collect)\n child.stderr?.on('data', collect)\n\n const timer = setTimeout(() => {\n child.kill('SIGKILL')\n reject(new Error(`\\`npx playwright install chromium\\` exceeded ${INSTALL_TIMEOUT_MS}ms`))\n }, INSTALL_TIMEOUT_MS)\n\n child.once('error', (error) => {\n clearTimeout(timer)\n reject(error)\n })\n child.once('close', (code) => {\n clearTimeout(timer)\n if (code === 0) {\n onProgress('chromium install complete')\n resolve()\n return\n }\n reject(new Error(`\\`npx playwright install chromium\\` failed (exit ${code}): ${tail.trim()}`))\n })\n })\n\n/**\n * Launches Chromium, downloading the binary once if it is missing.\n *\n * Installing the npm package does not fetch the ~150MB browser, so an operator\n * who enables this tier would otherwise hit a launch failure with no obvious\n * remedy. One retry only \u2014 a second failure is a real fault, not a cold cache.\n */\nexport async function launchChromium(\n browserType: BrowserType,\n options: { headless?: boolean; args?: string[] },\n deps: { install?: InstallRunner; onProgress?: (line: string) => void } = {},\n): Promise<BrowserHandle> {\n try {\n return await browserType.launch(options)\n } catch (error) {\n // Deliberately not retried without the sandbox. Falling back silently would\n // mean the security posture depends on whether a container happened to be\n // configured, with nobody told which one they got.\n if (isSandboxFailure(error)) {\n throw new SidecarError(\n `Chromium could not start its sandbox, and this adapter renders untrusted pages so it will not run without one. ` +\n `Fix the container (run as a non-root user, or grant SYS_ADMIN, or enable user namespaces), ` +\n `or accept the risk explicitly with ${SANDBOX_OPT_OUT_ENV}=1. Underlying: ${error instanceof Error ? error.message : String(error)}`,\n 'init',\n )\n }\n if (!isMissingBrowserError(error)) throw error\n const onProgress = deps.onProgress ?? (() => {})\n onProgress('chromium binary missing, running `npx playwright install chromium` (one-time, ~150MB)')\n try {\n await (deps.install ?? defaultInstallRunner)(onProgress)\n } catch (installError) {\n const detail = installError instanceof Error ? installError.message : String(installError)\n throw new SidecarError(\n `Playwright browser auto-install failed: ${detail}. Run \\`npx playwright install chromium\\` manually.`,\n 'init',\n )\n }\n return await browserType.launch(options)\n }\n}\n\nconst MAX_VERDICT_CACHE = 2048\n\n/**\n * Blocks private-origin navigations for the life of the context.\n *\n * The parent and the sidecar both vet the URL before a `goto`, but a page reached\n * through a legitimate public navigation can redirect or script-navigate itself\n * to a metadata endpoint, and those hops never pass through the RPC layer.\n *\n * Fails closed: Chromium resolves names with its own resolver, so a hostname\n * `node:dns` cannot vet must not be allowed through on the assumption it is dead.\n */\nexport async function installNavigationGuard(\n context: ContextHandle,\n options: { filterSubresources?: boolean; warn?: (message: string) => void } = {},\n): Promise<void> {\n if (typeof context.route !== 'function') {\n options.warn?.('context.route() unavailable \u2014 in-page navigation SSRF guard NOT installed')\n return\n }\n const filterSubresources = options.filterSubresources ?? false\n const verdicts = new Map<string, boolean>()\n const remember = (host: string, blocked: boolean): void => {\n if (verdicts.size >= MAX_VERDICT_CACHE) {\n const oldest = verdicts.keys().next().value\n if (oldest !== undefined) verdicts.delete(oldest)\n }\n verdicts.set(host, blocked)\n }\n\n await context.route('**/*', async (route) => {\n const request = route.request()\n const isNavigation = request.isNavigationRequest()\n if (!isNavigation && !filterSubresources) return route.continue()\n\n const url = request.url()\n let host: string\n try {\n host = new URL(url).host\n } catch {\n return isNavigation ? route.abort('blockedbyclient') : route.continue()\n }\n\n const cached = isNavigation ? undefined : verdicts.get(host)\n if (cached === true) return route.abort('blockedbyclient')\n if (cached === false) return route.continue()\n\n try {\n await assertPublicUrl(url, isNavigation ? 'browser navigation' : 'browser subresource', {\n failClosed: true,\n })\n remember(host, false)\n } catch {\n remember(host, true)\n return route.abort('blockedbyclient')\n }\n return route.continue()\n })\n}\n\n/**\n * Launch flags aimed at not announcing automation. Playwright's defaults set\n * `navigator.webdriver` and a headless UA, which the SERP endpoints this adapter\n * exists to reach specifically look for.\n *\n * `--disable-dev-shm-usage` is a sizing workaround, not a security control: it\n * makes Chromium write shared memory to /tmp instead of a container's small\n * /dev/shm. The sandbox is deliberately NOT disabled here \u2014 see\n * {@link resolveLaunchArgs}.\n */\nexport const LAUNCH_ARGS = [\n '--disable-blink-features=AutomationControlled',\n '--disable-dev-shm-usage',\n]\n\nexport const SANDBOX_OPT_OUT_ENV = 'OM_WEB_RESEARCH_BROWSER_NO_SANDBOX'\n\n/**\n * Launch flags for this process, sandbox included unless an operator opted out.\n *\n * This adapter exists to render pages chosen by a search engine or a model, so\n * the renderer is by definition parsing hostile input. `--no-sandbox` turns a\n * renderer compromise into code running as whatever user the app runs as, which\n * is the one mitigation Chromium provides for exactly this threat.\n *\n * It used to be on unconditionally because the sandbox needs privileges a\n * root-in-Docker container does not grant by default. That is a container\n * problem with container fixes (run as a non-root user, or grant `SYS_ADMIN`, or\n * enable user namespaces); silently shipping every deployment the insecure\n * variant to spare those is the wrong trade. The opt-out stays available for\n * environments that genuinely cannot, and it is loud.\n */\nexport function resolveLaunchArgs(env: NodeJS.ProcessEnv = process.env): string[] {\n return env[SANDBOX_OPT_OUT_ENV] === '1' ? [...LAUNCH_ARGS, '--no-sandbox'] : [...LAUNCH_ARGS]\n}\n\nexport function isSandboxDisabled(env: NodeJS.ProcessEnv = process.env): boolean {\n return env[SANDBOX_OPT_OUT_ENV] === '1'\n}\n\n/**\n * Chromium's refusal when it cannot set up its sandbox. The wording differs\n * between the root check and the namespace failure, so both shapes are matched.\n */\nexport function isSandboxFailure(error: unknown): boolean {\n if (!(error instanceof Error)) return false\n return (\n /running as root without --no-sandbox/i.test(error.message) ||\n /No usable sandbox/i.test(error.message) ||\n /clone helper|namespace sandbox|SUID sandbox/i.test(error.message)\n )\n}\n\nexport const DEFAULT_USER_AGENT =\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { spawn } from 'node:child_process'\nimport { assertPublicUrl, type LookupFn } from '@open-mercato/web-research'\nimport { SidecarError } from './protocol'\n\n/**\n * Structural projections of the Playwright surface we touch.\n *\n * Playwright is a real dependency of this package \u2014 installing the adapter gets\n * you the tooling it needs \u2014 but it is still reached through a dynamic import\n * inside the sidecar, so the app and MCP processes never load it. The types stay\n * structural for the same reason: nothing here should pull the SDK into a bundle\n * that only ever spawns the child.\n */\nexport type PageHandle = {\n goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>\n content(): Promise<string>\n url(): string\n close(): Promise<void>\n}\n\nexport type ContextHandle = {\n newPage(): Promise<unknown>\n close(): Promise<void>\n route?(pattern: string, handler: (route: RouteHandle) => Promise<void> | void): Promise<void>\n}\n\nexport type RouteHandle = {\n request(): { url(): string; isNavigationRequest(): boolean }\n abort(reason?: string): Promise<void>\n continue(): Promise<void>\n}\n\nexport type BrowserHandle = {\n newContext(options?: Record<string, unknown>): Promise<ContextHandle>\n close(): Promise<void>\n}\n\nexport type LaunchOptions = {\n headless?: boolean\n args?: string[]\n proxy?: { server: string; bypass?: string }\n}\n\nexport type BrowserType = {\n launch(options?: LaunchOptions): Promise<BrowserHandle>\n}\n\nfunction isModuleNotFound(message: string): boolean {\n return (\n /cannot find (package|module) ['\"]?playwright/i.test(message) ||\n /ERR_MODULE_NOT_FOUND/i.test(message) ||\n /failed to resolve ['\"]?playwright/i.test(message)\n )\n}\n\nexport async function importChromium(): Promise<BrowserType> {\n try {\n const playwright = (await import('playwright')) as unknown as { chromium: BrowserType }\n return playwright.chromium\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n // Playwright is a dependency of this package, so a module-not-found here\n // means a broken install (hoisting, a pruned image layer), not a missing opt-in.\n throw new SidecarError(\n `Playwright could not be loaded even though it is a dependency of this package \u2014 reinstall dependencies. Underlying: ${message}`,\n isModuleNotFound(message) ? 'needs-install' : 'init',\n )\n }\n}\n\n/**\n * Playwright's launch error when the npm package is installed but the browser\n * binary was never downloaded. The wording has been stable across 1.x, and it is\n * the only signal Playwright gives for this case.\n */\nfunction isMissingBrowserError(error: unknown): boolean {\n return error instanceof Error && /Executable doesn'?t exist at/i.test(error.message)\n}\n\n/** Generous, but bounded \u2014 an unbounded install hangs the sidecar forever. */\nconst INSTALL_TIMEOUT_MS = 10 * 60_000\n\nexport type InstallRunner = (onProgress: (line: string) => void) => Promise<void>\n\nconst defaultInstallRunner: InstallRunner = async (onProgress) =>\n new Promise((resolve, reject) => {\n const child = spawn('npx', ['playwright', 'install', 'chromium'], { stdio: ['ignore', 'pipe', 'pipe'] })\n let tail = ''\n const collect = (chunk: Buffer): void => {\n tail = `${tail}${chunk.toString('utf8')}`.slice(-4000)\n }\n child.stdout?.on('data', collect)\n child.stderr?.on('data', collect)\n\n const timer = setTimeout(() => {\n child.kill('SIGKILL')\n reject(new Error(`\\`npx playwright install chromium\\` exceeded ${INSTALL_TIMEOUT_MS}ms`))\n }, INSTALL_TIMEOUT_MS)\n\n child.once('error', (error) => {\n clearTimeout(timer)\n reject(error)\n })\n child.once('close', (code) => {\n clearTimeout(timer)\n if (code === 0) {\n onProgress('chromium install complete')\n resolve()\n return\n }\n reject(new Error(`\\`npx playwright install chromium\\` failed (exit ${code}): ${tail.trim()}`))\n })\n })\n\n/**\n * Launches Chromium, downloading the binary once if it is missing.\n *\n * Installing the npm package does not fetch the ~150MB browser, so an operator\n * who enables this tier would otherwise hit a launch failure with no obvious\n * remedy. One retry only \u2014 a second failure is a real fault, not a cold cache.\n */\nexport async function launchChromium(\n browserType: BrowserType,\n options: LaunchOptions,\n deps: { install?: InstallRunner; onProgress?: (line: string) => void } = {},\n): Promise<BrowserHandle> {\n try {\n return await browserType.launch(options)\n } catch (error) {\n // Deliberately not retried without the sandbox. Falling back silently would\n // mean the security posture depends on whether a container happened to be\n // configured, with nobody told which one they got.\n if (isSandboxFailure(error)) {\n throw new SidecarError(\n `Chromium could not start its sandbox, and this adapter renders untrusted pages so it will not run without one. ` +\n `Fix the container (run as a non-root user, or grant SYS_ADMIN, or enable user namespaces), ` +\n `or accept the risk explicitly with ${SANDBOX_OPT_OUT_ENV}=1. Underlying: ${error instanceof Error ? error.message : String(error)}`,\n 'init',\n )\n }\n if (!isMissingBrowserError(error)) throw error\n const onProgress = deps.onProgress ?? (() => {})\n onProgress('chromium binary missing, running `npx playwright install chromium` (one-time, ~150MB)')\n try {\n await (deps.install ?? defaultInstallRunner)(onProgress)\n } catch (installError) {\n const detail = installError instanceof Error ? installError.message : String(installError)\n throw new SidecarError(\n `Playwright browser auto-install failed: ${detail}. Run \\`npx playwright install chromium\\` manually.`,\n 'init',\n )\n }\n return await browserType.launch(options)\n }\n}\n\nconst MAX_VERDICT_CACHE = 2048\n\nexport type NavigationGuardOptions = {\n /** @deprecated Every routed request is filtered now; this option is ignored. */\n filterSubresources?: boolean\n warn?: (message: string) => void\n lookup?: LookupFn\n}\n\n/**\n * Aborts every routed request whose URL is not public, navigations and\n * subresources alike, for the life of the context.\n *\n * This is an early-abort layer, not the egress boundary: `context.route()` never\n * sees redirect hops, WebSockets, service workers or UDP, and it vets the name\n * with `node:dns` while Chromium dials whatever its own resolver returns. The\n * boundary is the pinned egress proxy every connection is forced through (see\n * `egressProxy.ts`). Navigation verdicts are never cached; subresource verdicts\n * are cached per host for the life of the context.\n *\n * Fails closed: a context without request interception is refused, and a URL\n * that cannot be parsed is aborted.\n */\nexport async function installNavigationGuard(\n context: ContextHandle,\n options: NavigationGuardOptions = {},\n): Promise<void> {\n if (typeof context.route !== 'function') {\n throw new SidecarError('context.route() is unavailable, so the request guard cannot be installed', 'init')\n }\n const verdicts = new Map<string, boolean>()\n const remember = (host: string, blocked: boolean): void => {\n if (verdicts.size >= MAX_VERDICT_CACHE) {\n const oldest = verdicts.keys().next().value\n if (oldest !== undefined) verdicts.delete(oldest)\n }\n verdicts.set(host, blocked)\n }\n\n await context.route('**/*', async (route) => {\n const request = route.request()\n const isNavigation = request.isNavigationRequest()\n const url = request.url()\n let host: string\n try {\n host = new URL(url).host\n } catch {\n return route.abort('blockedbyclient')\n }\n\n const cached = isNavigation ? undefined : verdicts.get(host)\n if (cached === true) return route.abort('blockedbyclient')\n if (cached === false) return route.continue()\n\n try {\n await assertPublicUrl(url, isNavigation ? 'browser navigation' : 'browser subresource', {\n failClosed: true,\n ...(options.lookup ? { lookup: options.lookup } : {}),\n })\n remember(host, false)\n } catch {\n remember(host, true)\n return route.abort('blockedbyclient')\n }\n return route.continue()\n })\n}\n\n/**\n * Denies Chromium its own DNS, so any path that bypasses the egress proxy by\n * hostname fails instead of resolving. The proxy address is a literal, so it is\n * unaffected. Only meaningful alongside the egress proxy, so it is kept out of\n * {@link LAUNCH_ARGS}.\n */\nexport const RESOLVER_LOCKDOWN_ARGS = ['--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1']\n\n/**\n * Launch flags aimed at not announcing automation. Playwright's defaults set\n * `navigator.webdriver` and a headless UA, which the SERP endpoints this adapter\n * exists to reach specifically look for.\n *\n * `--disable-dev-shm-usage` is a sizing workaround, not a security control: it\n * makes Chromium write shared memory to /tmp instead of a container's small\n * /dev/shm. The WebRTC policy and `--disable-quic` keep UDP from leaving outside\n * the egress proxy. The sandbox is deliberately NOT disabled here \u2014 see\n * {@link resolveLaunchArgs}.\n */\nexport const LAUNCH_ARGS = [\n '--disable-blink-features=AutomationControlled',\n '--disable-dev-shm-usage',\n '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',\n '--disable-quic',\n]\n\nexport const SANDBOX_OPT_OUT_ENV = 'OM_WEB_RESEARCH_BROWSER_NO_SANDBOX'\n\n/**\n * Launch flags for this process, sandbox included unless an operator opted out.\n *\n * This adapter exists to render pages chosen by a search engine or a model, so\n * the renderer is by definition parsing hostile input. `--no-sandbox` turns a\n * renderer compromise into code running as whatever user the app runs as, which\n * is the one mitigation Chromium provides for exactly this threat.\n *\n * It used to be on unconditionally because the sandbox needs privileges a\n * root-in-Docker container does not grant by default. That is a container\n * problem with container fixes (run as a non-root user, or grant `SYS_ADMIN`, or\n * enable user namespaces); silently shipping every deployment the insecure\n * variant to spare those is the wrong trade. The opt-out stays available for\n * environments that genuinely cannot, and it is loud.\n */\nexport function resolveLaunchArgs(env: NodeJS.ProcessEnv = process.env): string[] {\n return env[SANDBOX_OPT_OUT_ENV] === '1' ? [...LAUNCH_ARGS, '--no-sandbox'] : [...LAUNCH_ARGS]\n}\n\nexport function isSandboxDisabled(env: NodeJS.ProcessEnv = process.env): boolean {\n return env[SANDBOX_OPT_OUT_ENV] === '1'\n}\n\n/**\n * Chromium's refusal when it cannot set up its sandbox. The wording differs\n * between the root check and the namespace failure, so both shapes are matched.\n */\nexport function isSandboxFailure(error: unknown): boolean {\n if (!(error instanceof Error)) return false\n return (\n /running as root without --no-sandbox/i.test(error.message) ||\n /No usable sandbox/i.test(error.message) ||\n /clone helper|namespace sandbox|SUID sandbox/i.test(error.message)\n )\n}\n\nexport const DEFAULT_USER_AGENT =\n 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/web-research-browser",
3
- "version": "0.9.1-develop.7548.1.ee60458c45",
3
+ "version": "0.9.1-develop.7553.1.8dbab05b5a",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,12 +38,12 @@
38
38
  "@open-mercato/web-research": "^0.6.0"
39
39
  },
40
40
  "dependencies": {
41
- "@open-mercato/web-research-serp": "0.9.1-develop.7548.1.ee60458c45",
41
+ "@open-mercato/web-research-serp": "0.9.1-develop.7553.1.8dbab05b5a",
42
42
  "playwright": "^1.40.0",
43
43
  "zod": "^4.4.3"
44
44
  },
45
45
  "devDependencies": {
46
- "@open-mercato/web-research": "0.9.1-develop.7548.1.ee60458c45",
46
+ "@open-mercato/web-research": "0.9.1-develop.7553.1.8dbab05b5a",
47
47
  "@types/jest": "^30.0.0",
48
48
  "@types/node": "^26.0.1",
49
49
  "esbuild": "^0.28.1",
@@ -0,0 +1,481 @@
1
+ import { createServer as createHttpServer, request as sendHttp, type IncomingHttpHeaders, type Server } from 'node:http'
2
+ import { connect as netConnect, createServer as createTcpServer, type AddressInfo, type Socket } from 'node:net'
3
+ import type { LookupFn } from '@open-mercato/web-research'
4
+ import { pinnedConnect, startEgressProxy, type EgressDialTarget, type EgressProxy } from '../egressProxy'
5
+
6
+ const PUBLIC_IP = '93.184.216.34'
7
+ const RESET_IP = '93.184.216.35'
8
+ const DEAD_IP = '93.184.216.36'
9
+ const MALFORMED_IP = '93.184.216.37'
10
+ const PUBLIC_IPV6 = '2606:2800:220:1:248:1893:25c8:1946'
11
+
12
+ type Recorded = { host: string | undefined; url: string | undefined; headers: IncomingHttpHeaders }
13
+
14
+ type Harness = {
15
+ proxy: EgressProxy
16
+ proxyPort: number
17
+ dials: EgressDialTarget[]
18
+ privateConnections: () => number
19
+ publicRequests: Recorded[]
20
+ publicSockets: Set<Socket>
21
+ flipAnswers: string[]
22
+ }
23
+
24
+ const servers: Server[] = []
25
+ const tcpServers: Array<ReturnType<typeof createTcpServer>> = []
26
+ let harness: Harness
27
+
28
+ async function listen(server: Server | ReturnType<typeof createTcpServer>): Promise<number> {
29
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
30
+ return (server.address() as AddressInfo).port
31
+ }
32
+
33
+ function closeAll(): Promise<void[]> {
34
+ const closing = [...servers, ...tcpServers].map(
35
+ (server) =>
36
+ new Promise<void>((resolve) => {
37
+ server.close(() => resolve())
38
+ }),
39
+ )
40
+ servers.length = 0
41
+ tcpServers.length = 0
42
+ return Promise.all(closing)
43
+ }
44
+
45
+ async function buildHarness(): Promise<Harness> {
46
+ let privateHits = 0
47
+ const privateServer = createTcpServer((socket) => {
48
+ privateHits += 1
49
+ socket.on('error', () => undefined)
50
+ socket.end('HTTP/1.1 200 OK\r\nContent-Length: 6\r\n\r\nSECRET')
51
+ })
52
+ tcpServers.push(privateServer)
53
+ const privatePort = await listen(privateServer)
54
+
55
+ const publicRequests: Recorded[] = []
56
+ const publicSockets = new Set<Socket>()
57
+ const publicServer = createHttpServer((req, res) => {
58
+ publicRequests.push({ host: req.headers.host, url: req.url, headers: req.headers })
59
+ if (req.url === '/redirect') {
60
+ res.writeHead(302, { location: 'http://private.test/secret' })
61
+ res.end()
62
+ return
63
+ }
64
+ res.writeHead(200, { 'content-type': 'text/plain' })
65
+ res.end(`public:${req.url}`)
66
+ })
67
+ publicServer.on('connection', (socket) => {
68
+ publicSockets.add(socket)
69
+ socket.once('close', () => publicSockets.delete(socket))
70
+ })
71
+ servers.push(publicServer)
72
+ const publicPort = await listen(publicServer)
73
+
74
+ const resetServer = createTcpServer((socket) => {
75
+ socket.on('error', () => undefined)
76
+ socket.resetAndDestroy()
77
+ })
78
+ tcpServers.push(resetServer)
79
+ const resetPort = await listen(resetServer)
80
+
81
+ const malformedServer = createTcpServer((socket) => {
82
+ socket.on('error', () => undefined)
83
+ let head = ''
84
+ socket.on('data', (chunk: Buffer) => {
85
+ head += chunk.toString('latin1')
86
+ if (!head.includes('\r\n\r\n')) return
87
+ const statusLine = head.startsWith('GET /low-status') ? 'HTTP/1.1 099 X' : 'HTTP/1.1 200 O\x01K'
88
+ socket.end(`${statusLine}\r\nContent-Length: 2\r\n\r\nok`)
89
+ })
90
+ })
91
+ tcpServers.push(malformedServer)
92
+ const malformedPort = await listen(malformedServer)
93
+
94
+ const deadServer = createTcpServer()
95
+ const deadPort = await listen(deadServer)
96
+ await new Promise<void>((resolve) => deadServer.close(() => resolve()))
97
+
98
+ const flipAnswers: string[] = []
99
+ const table: Record<string, string> = {
100
+ 'public.test': PUBLIC_IP,
101
+ 'redirect.test': PUBLIC_IP,
102
+ 'private.test': '10.0.0.5',
103
+ 'metadata.test': '169.254.169.254',
104
+ 'reset.test': RESET_IP,
105
+ 'dead.test': DEAD_IP,
106
+ 'malformed.test': MALFORMED_IP,
107
+ 'under_score.test': PUBLIC_IP,
108
+ }
109
+ const lookup: LookupFn = async (hostname) => {
110
+ if (hostname === 'flip.test') {
111
+ const address = flipAnswers.shift()
112
+ if (!address) throw new Error('no answer')
113
+ return [{ address, family: 4 }]
114
+ }
115
+ const address = table[hostname]
116
+ if (!address) throw new Error(`ENOTFOUND ${hostname}`)
117
+ return [{ address, family: 4 }]
118
+ }
119
+
120
+ const dials: EgressDialTarget[] = []
121
+ const portFor = (addresses: readonly string[]): number => {
122
+ if (addresses.length === 1 && (addresses[0] === PUBLIC_IP || addresses[0] === PUBLIC_IPV6)) return publicPort
123
+ if (addresses.length === 1 && addresses[0] === RESET_IP) return resetPort
124
+ if (addresses.length === 1 && addresses[0] === DEAD_IP) return deadPort
125
+ if (addresses.length === 1 && addresses[0] === MALFORMED_IP) return malformedPort
126
+ return privatePort
127
+ }
128
+ const proxy = await startEgressProxy({
129
+ lookup,
130
+ connect: (target) => {
131
+ dials.push(target)
132
+ return netConnect({ host: '127.0.0.1', port: portFor(target.addresses) })
133
+ },
134
+ })
135
+ return {
136
+ proxy,
137
+ proxyPort: Number(new URL(proxy.url).port),
138
+ dials,
139
+ privateConnections: () => privateHits,
140
+ publicRequests,
141
+ publicSockets,
142
+ flipAnswers,
143
+ }
144
+ }
145
+
146
+ type ConnectResult = { status: number; socket: Socket }
147
+
148
+ function openConnect(authority: string): Promise<ConnectResult> {
149
+ return new Promise((resolve, reject) => {
150
+ const socket = netConnect({ host: '127.0.0.1', port: harness.proxyPort })
151
+ let buffered = ''
152
+ const onData = (chunk: Buffer): void => {
153
+ buffered += chunk.toString('latin1')
154
+ const end = buffered.indexOf('\r\n\r\n')
155
+ if (end === -1) return
156
+ socket.removeListener('data', onData)
157
+ const status = Number(/^HTTP\/1\.1 (\d{3})/.exec(buffered)?.[1] ?? 0)
158
+ resolve({ status, socket })
159
+ }
160
+ socket.on('data', onData)
161
+ socket.on('error', reject)
162
+ socket.once('close', () => resolve({ status: Number(/^HTTP\/1\.1 (\d{3})/.exec(buffered)?.[1] ?? 0), socket }))
163
+ socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`)
164
+ })
165
+ }
166
+
167
+ async function connectStatus(authority: string): Promise<number> {
168
+ const { status, socket } = await openConnect(authority)
169
+ socket.destroy()
170
+ return status
171
+ }
172
+
173
+ function readAll(socket: Socket): Promise<string> {
174
+ return new Promise((resolve) => {
175
+ let text = ''
176
+ socket.on('data', (chunk: Buffer) => {
177
+ text += chunk.toString('utf8')
178
+ })
179
+ socket.once('close', () => resolve(text))
180
+ })
181
+ }
182
+
183
+ type ProxiedResponse = { status: number; headers: IncomingHttpHeaders; body: string }
184
+
185
+ function proxiedRequest(path: string, headers: Record<string, string> = {}): Promise<ProxiedResponse> {
186
+ return new Promise((resolve, reject) => {
187
+ const request = sendHttp(
188
+ { host: '127.0.0.1', port: harness.proxyPort, path, method: 'GET', headers, agent: false },
189
+ (response) => {
190
+ let body = ''
191
+ response.setEncoding('utf8')
192
+ response.on('data', (chunk: string) => {
193
+ body += chunk
194
+ })
195
+ response.on('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body }))
196
+ },
197
+ )
198
+ request.on('error', reject)
199
+ request.end()
200
+ })
201
+ }
202
+
203
+ describe('browser egress proxy (#7218)', () => {
204
+ beforeEach(async () => {
205
+ harness = await buildHarness()
206
+ })
207
+
208
+ afterEach(async () => {
209
+ expect(harness.privateConnections()).toBe(0)
210
+ await harness.proxy.close()
211
+ await closeAll()
212
+ })
213
+
214
+ it('refuses a CONNECT to a host that resolves privately without dialling it', async () => {
215
+ expect(await connectStatus('private.test:443')).toBe(403)
216
+ expect(await connectStatus('metadata.test:80')).toBe(403)
217
+ expect(harness.dials).toEqual([])
218
+ })
219
+
220
+ it.each([
221
+ '[::1]:443',
222
+ '[::ffff:127.0.0.1]:443',
223
+ '127.0.0.1:443',
224
+ '169.254.169.254:80',
225
+ 'localhost:443',
226
+ '0x7f.1:443',
227
+ ])('refuses the private literal %s without dialling it', async (authority) => {
228
+ expect(await connectStatus(authority)).toBe(403)
229
+ expect(harness.dials).toEqual([])
230
+ })
231
+
232
+ it.each(['a@b:1', 'public.test', 'public.test:0', 'public.test:70000', '[::1:443', 'public.test:443/x'])(
233
+ 'answers the malformed authority %s with 400',
234
+ async (authority) => {
235
+ expect(await connectStatus(authority)).toBe(400)
236
+ expect(harness.dials).toEqual([])
237
+ },
238
+ )
239
+
240
+ it('tunnels a public CONNECT to exactly the vetted address', async () => {
241
+ const { status, socket } = await openConnect('public.test:443')
242
+ expect(status).toBe(200)
243
+ expect(harness.dials).toEqual([{ host: 'public.test', port: 443, addresses: [PUBLIC_IP] }])
244
+
245
+ const received = readAll(socket)
246
+ socket.write('GET /tunnel HTTP/1.1\r\nHost: public.test\r\nConnection: close\r\n\r\n')
247
+ expect(await received).toContain('public:/tunnel')
248
+ })
249
+
250
+ it('dials port 80 when the CONNECT authority names it', async () => {
251
+ const { status, socket } = await openConnect('public.test:80')
252
+ socket.destroy()
253
+ expect(status).toBe(200)
254
+ expect(harness.dials[0]?.port).toBe(80)
255
+ })
256
+
257
+ it('dials a bracketed public IPv6 literal through the pinned dial', async () => {
258
+ const { status, socket } = await openConnect(`[${PUBLIC_IPV6}]:443`)
259
+ socket.destroy()
260
+ expect(status).toBe(200)
261
+ expect(harness.dials).toEqual([{ host: PUBLIC_IPV6, port: 443, addresses: [PUBLIC_IPV6] }])
262
+ })
263
+
264
+ it('re-resolves every connection, so a host that turns private is refused', async () => {
265
+ harness.flipAnswers.push(PUBLIC_IP, '127.0.0.1')
266
+
267
+ const first = await openConnect('flip.test:443')
268
+ first.socket.destroy()
269
+ expect(first.status).toBe(200)
270
+ expect(await connectStatus('flip.test:443')).toBe(403)
271
+ expect(harness.dials).toHaveLength(1)
272
+ })
273
+
274
+ it('proxies an absolute-form public GET with the vetted Host and no proxy or hop-by-hop headers', async () => {
275
+ const response = await proxiedRequest('http://public.test/hello?q=1', {
276
+ host: 'evil.internal',
277
+ 'proxy-authorization': 'Basic c2VjcmV0',
278
+ connection: 'keep-alive, x-hop',
279
+ 'x-hop': 'drop-me',
280
+ 'x-keep': 'kept',
281
+ })
282
+
283
+ expect(response.status).toBe(200)
284
+ expect(response.body).toBe('public:/hello?q=1')
285
+ expect(harness.dials).toEqual([{ host: 'public.test', port: 80, addresses: [PUBLIC_IP] }])
286
+ const seen = harness.publicRequests[0]
287
+ expect(seen?.host).toBe('public.test')
288
+ expect(seen?.headers['proxy-authorization']).toBeUndefined()
289
+ expect(seen?.headers['x-hop']).toBeUndefined()
290
+ expect(seen?.headers['x-keep']).toBe('kept')
291
+ })
292
+
293
+ it('refuses an absolute-form GET to a private target', async () => {
294
+ expect((await proxiedRequest('http://private.test/secret')).status).toBe(403)
295
+ expect((await proxiedRequest('http://127.0.0.1:5432/')).status).toBe(403)
296
+ expect((await proxiedRequest('http://[::1]/')).status).toBe(403)
297
+ expect(harness.dials).toEqual([])
298
+ })
299
+
300
+ it('re-vets a redirect hop followed as a new request', async () => {
301
+ const redirect = await proxiedRequest('http://redirect.test/redirect')
302
+ expect(redirect.status).toBe(302)
303
+ const location = String(redirect.headers.location)
304
+ expect(location).toBe('http://private.test/secret')
305
+
306
+ expect((await proxiedRequest(location)).status).toBe(403)
307
+ expect(harness.dials).toHaveLength(1)
308
+ })
309
+
310
+ it('answers an origin-form request with 400', async () => {
311
+ expect((await proxiedRequest('/hello')).status).toBe(400)
312
+ expect(harness.dials).toEqual([])
313
+ })
314
+
315
+ it('refuses an absolute-form request that asks for a protocol upgrade', async () => {
316
+ const response = await proxiedRequest('http://public.test/socket', {
317
+ connection: 'Upgrade',
318
+ upgrade: 'websocket',
319
+ 'sec-websocket-version': '13',
320
+ 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==',
321
+ })
322
+
323
+ expect(response.status).toBe(400)
324
+ expect(harness.dials).toEqual([])
325
+ expect(harness.publicRequests).toEqual([])
326
+ })
327
+
328
+ it('answers 502 when the vetted address cannot be reached', async () => {
329
+ expect(await connectStatus('dead.test:443')).toBe(502)
330
+ expect((await proxiedRequest('http://dead.test/')).status).toBe(502)
331
+ })
332
+
333
+ it('survives an upstream reset', async () => {
334
+ const tunnel = await openConnect('reset.test:443')
335
+ const closed = new Promise<void>((resolve) => tunnel.socket.once('close', () => resolve()))
336
+ tunnel.socket.on('error', () => undefined)
337
+ await closed
338
+
339
+ expect((await proxiedRequest('http://reset.test/')).status).toBe(502)
340
+ expect((await proxiedRequest('http://public.test/after')).status).toBe(200)
341
+ })
342
+
343
+ it('drops a reason phrase with control bytes instead of crashing on it', async () => {
344
+ const response = await proxiedRequest('http://malformed.test/bad-reason')
345
+ expect(response.status).toBe(200)
346
+ expect(response.body).toBe('ok')
347
+ expect((await proxiedRequest('http://public.test/after')).status).toBe(200)
348
+ })
349
+
350
+ it('answers 502 instead of crashing when an upstream sends a status below 200', async () => {
351
+ const outcome = await proxiedRequest('http://malformed.test/low-status').then(
352
+ (response) => response.status,
353
+ () => 'closed',
354
+ )
355
+ expect([502, 'closed']).toContain(outcome)
356
+ expect((await proxiedRequest('http://public.test/after')).status).toBe(200)
357
+ })
358
+
359
+ it('accepts an underscore in a CONNECT hostname and still vets it', async () => {
360
+ const { status, socket } = await openConnect('under_score.test:443')
361
+ socket.destroy()
362
+ expect(status).toBe(200)
363
+ expect(harness.dials).toEqual([{ host: 'under_score.test', port: 443, addresses: [PUBLIC_IP] }])
364
+ })
365
+
366
+ it('tears down live tunnels on close', async () => {
367
+ const tunnel = await openConnect('public.test:443')
368
+ expect(tunnel.status).toBe(200)
369
+ tunnel.socket.on('error', () => undefined)
370
+ const clientClosed = new Promise<void>((resolve) => tunnel.socket.once('close', () => resolve()))
371
+
372
+ await harness.proxy.close()
373
+ await clientClosed
374
+
375
+ await new Promise<void>((resolve) => setImmediate(resolve))
376
+ expect(harness.publicSockets.size).toBe(0)
377
+ await expect(
378
+ new Promise((resolve, reject) => {
379
+ const probe = netConnect({ host: '127.0.0.1', port: harness.proxyPort })
380
+ probe.once('connect', () => {
381
+ probe.destroy()
382
+ resolve('connected')
383
+ })
384
+ probe.once('error', reject)
385
+ }),
386
+ ).rejects.toThrow(/ECONNREFUSED/)
387
+ })
388
+ })
389
+
390
+ describe('pinned dial and vetting limits (#7218)', () => {
391
+ it('connects the default dial to the vetted address without resolving the hostname', async () => {
392
+ const target = createTcpServer((socket) => socket.end())
393
+ const port = await listen(target)
394
+ try {
395
+ const socket = pinnedConnect({ host: 'pinned.invalid', port, addresses: ['127.0.0.1'] })
396
+ const remote = await new Promise<string | undefined>((resolve, reject) => {
397
+ socket.once('connect', () => resolve(socket.remoteAddress))
398
+ socket.once('error', reject)
399
+ })
400
+ socket.destroy()
401
+ expect(remote).toBe('127.0.0.1')
402
+ } finally {
403
+ await new Promise<void>((resolve) => target.close(() => resolve()))
404
+ }
405
+ })
406
+
407
+ it('fails the default dial when nothing was vetted instead of falling back to DNS', async () => {
408
+ const socket = pinnedConnect({ host: 'localhost', port: 9, addresses: [] })
409
+ await expect(
410
+ new Promise((resolve, reject) => {
411
+ socket.once('connect', () => resolve('connected'))
412
+ socket.once('error', reject)
413
+ }),
414
+ ).rejects.toThrow(/No pinned address/)
415
+ socket.destroy()
416
+ })
417
+
418
+ it('refuses a connection whose name cannot be vetted in time', async () => {
419
+ const dials: EgressDialTarget[] = []
420
+ const proxy = await startEgressProxy({
421
+ vetTimeoutMs: 50,
422
+ lookup: () => new Promise(() => undefined),
423
+ connect: (dialTarget) => {
424
+ dials.push(dialTarget)
425
+ return netConnect({ host: '127.0.0.1', port: 9 })
426
+ },
427
+ })
428
+ try {
429
+ const status = await new Promise<number>((resolve, reject) => {
430
+ const socket = netConnect({ host: '127.0.0.1', port: Number(new URL(proxy.url).port) })
431
+ let buffered = ''
432
+ socket.on('data', (chunk: Buffer) => {
433
+ buffered += chunk.toString('latin1')
434
+ })
435
+ socket.once('close', () => resolve(Number(/^HTTP\/1\.1 (\d{3})/.exec(buffered)?.[1] ?? 0)))
436
+ socket.on('error', reject)
437
+ socket.write('CONNECT slow.test:443 HTTP/1.1\r\nHost: slow.test:443\r\n\r\n')
438
+ })
439
+ expect(status).toBe(504)
440
+ expect(dials).toEqual([])
441
+ } finally {
442
+ await proxy.close()
443
+ }
444
+ })
445
+
446
+ it('does not dial for a client that left while its destination was being vetted', async () => {
447
+ const dials: EgressDialTarget[] = []
448
+ let answer: (records: Array<{ address: string; family: number }>) => void = () => undefined
449
+ let lookupStarted: () => void = () => undefined
450
+ const started = new Promise<void>((resolve) => {
451
+ lookupStarted = resolve
452
+ })
453
+ const proxy = await startEgressProxy({
454
+ lookup: () =>
455
+ new Promise((resolve) => {
456
+ answer = resolve
457
+ lookupStarted()
458
+ }),
459
+ connect: (dialTarget) => {
460
+ dials.push(dialTarget)
461
+ return netConnect({ host: '127.0.0.1', port: 9 })
462
+ },
463
+ })
464
+ try {
465
+ const client = netConnect({ host: '127.0.0.1', port: Number(new URL(proxy.url).port) })
466
+ client.on('error', () => undefined)
467
+ client.write('CONNECT leaving.test:443 HTTP/1.1\r\nHost: leaving.test:443\r\n\r\n')
468
+ await started
469
+ const clientClosed = new Promise<void>((resolve) => client.once('close', () => resolve()))
470
+ client.destroy()
471
+ await clientClosed
472
+ await new Promise((resolve) => setTimeout(resolve, 20))
473
+ answer([{ address: PUBLIC_IP, family: 4 }])
474
+ await new Promise((resolve) => setTimeout(resolve, 20))
475
+
476
+ expect(dials).toEqual([])
477
+ } finally {
478
+ await proxy.close()
479
+ }
480
+ })
481
+ })