@open-mercato/web-research-browser 0.9.1-develop.7547.1.ade91a3f6b → 0.9.1-develop.7552.1.4acf199f7a

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.
@@ -0,0 +1,345 @@
1
+ import {
2
+ createServer,
3
+ request as sendHttp,
4
+ type ClientRequest,
5
+ type IncomingHttpHeaders,
6
+ type IncomingMessage,
7
+ type OutgoingHttpHeaders,
8
+ type ServerResponse,
9
+ } from 'node:http'
10
+ import { connect as netConnect, type Socket } from 'node:net'
11
+ import type { Duplex } from 'node:stream'
12
+ import { assertPublicUrl, createPinnedLookup, type LookupFn, type VettedUrl } from '@open-mercato/web-research'
13
+
14
+ export type EgressDialTarget = {
15
+ readonly host: string
16
+ readonly port: number
17
+ /** The addresses `assertPublicUrl` vetted for this connection; the only ones a dial may use. */
18
+ readonly addresses: readonly string[]
19
+ }
20
+
21
+ export type EgressConnect = (target: EgressDialTarget) => Socket
22
+
23
+ export type EgressProxyOptions = {
24
+ readonly lookup?: LookupFn
25
+ readonly connect?: EgressConnect
26
+ readonly vetTimeoutMs?: number
27
+ readonly dialTimeoutMs?: number
28
+ readonly idleTimeoutMs?: number
29
+ }
30
+
31
+ export type EgressProxy = {
32
+ readonly url: string
33
+ close(): Promise<void>
34
+ }
35
+
36
+ const EGRESS_VET_TIMEOUT_MS = 10_000
37
+ const EGRESS_DIAL_TIMEOUT_MS = 10_000
38
+ const EGRESS_IDLE_TIMEOUT_MS = 120_000
39
+
40
+ const AUTHORITY_PATTERN = /^(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9._-]+):(\d{1,5})$/
41
+
42
+ const HOP_BY_HOP_HEADERS = new Set([
43
+ 'connection',
44
+ 'keep-alive',
45
+ 'proxy-authenticate',
46
+ 'proxy-authorization',
47
+ 'proxy-connection',
48
+ 'te',
49
+ 'trailer',
50
+ 'transfer-encoding',
51
+ 'upgrade',
52
+ 'host',
53
+ ])
54
+
55
+ const STATUS_TEXT: Record<number, string> = {
56
+ 400: 'Bad Request',
57
+ 403: 'Forbidden',
58
+ 502: 'Bad Gateway',
59
+ 504: 'Gateway Timeout',
60
+ }
61
+
62
+ /** Dials only the vetted addresses: the hostname is never handed to DNS. */
63
+ export const pinnedConnect: EgressConnect = ({ host, port, addresses }) =>
64
+ netConnect({ host, port, lookup: createPinnedLookup(addresses), autoSelectFamily: true })
65
+
66
+ function parseConnectAuthority(raw: string): { host: string; port: number } | null {
67
+ const match = AUTHORITY_PATTERN.exec(raw)
68
+ if (!match) return null
69
+ const port = Number(match[2])
70
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null
71
+ return { host: match[1], port }
72
+ }
73
+
74
+ function parseAbsoluteForm(raw: string): URL | null {
75
+ if (!/^http:\/\//i.test(raw)) return null
76
+ try {
77
+ const url = new URL(raw)
78
+ if (url.protocol !== 'http:' || url.hostname === '') return null
79
+ return url
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ function forwardableHeaders(headers: IncomingHttpHeaders): OutgoingHttpHeaders {
86
+ const listed = new Set(
87
+ String(headers.connection ?? '')
88
+ .split(',')
89
+ .map((token) => token.trim().toLowerCase())
90
+ .filter((token) => token.length > 0),
91
+ )
92
+ const result: OutgoingHttpHeaders = {}
93
+ for (const [name, value] of Object.entries(headers)) {
94
+ if (value === undefined) continue
95
+ const key = name.toLowerCase()
96
+ if (HOP_BY_HOP_HEADERS.has(key) || key.startsWith('proxy-') || listed.has(key)) continue
97
+ result[key] = value
98
+ }
99
+ return result
100
+ }
101
+
102
+ function dialHost(vetted: VettedUrl): string {
103
+ return vetted.url.hostname.replace(/^\[|\]$/g, '')
104
+ }
105
+
106
+ function refuseSocket(socket: Duplex, status: number): void {
107
+ if (socket.destroyed || !socket.writable) {
108
+ socket.destroy()
109
+ return
110
+ }
111
+ socket.end(`HTTP/1.1 ${status} ${STATUS_TEXT[status] ?? 'Error'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, () =>
112
+ socket.destroy(),
113
+ )
114
+ }
115
+
116
+ function refuseResponse(res: ServerResponse, status: number, reason: string): void {
117
+ if (res.headersSent || res.destroyed) {
118
+ res.destroy()
119
+ return
120
+ }
121
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' })
122
+ res.end(`om-web-research egress proxy: ${reason}\n`)
123
+ }
124
+
125
+ /**
126
+ * Loopback HTTP proxy that every connection of the sidecar's Chromium goes through.
127
+ *
128
+ * Each CONNECT tunnel and each absolute-form request is vetted with
129
+ * `assertPublicUrl` on its own, then dialled only to the addresses that check
130
+ * returned, so redirect hops, WebSockets, workers and a host whose DNS answer
131
+ * changes between connections are all held to the same rule. Nothing is cached.
132
+ */
133
+ export async function startEgressProxy(options: EgressProxyOptions = {}): Promise<EgressProxy> {
134
+ const connect = options.connect ?? pinnedConnect
135
+ const vetTimeoutMs = options.vetTimeoutMs ?? EGRESS_VET_TIMEOUT_MS
136
+ const dialTimeoutMs = options.dialTimeoutMs ?? EGRESS_DIAL_TIMEOUT_MS
137
+ const idleTimeoutMs = options.idleTimeoutMs ?? EGRESS_IDLE_TIMEOUT_MS
138
+ const sockets = new Set<Duplex>()
139
+ let closed = false
140
+
141
+ const track = (socket: Duplex): void => {
142
+ sockets.add(socket)
143
+ socket.once('close', () => sockets.delete(socket))
144
+ }
145
+
146
+ const vet = (rawUrl: string): Promise<VettedUrl | 'blocked' | 'timeout'> =>
147
+ new Promise((resolve) => {
148
+ const timer = setTimeout(() => resolve('timeout'), vetTimeoutMs)
149
+ assertPublicUrl(rawUrl, 'browser egress', {
150
+ failClosed: true,
151
+ ...(options.lookup ? { lookup: options.lookup } : {}),
152
+ }).then(
153
+ (vetted) => {
154
+ clearTimeout(timer)
155
+ resolve(vetted)
156
+ },
157
+ () => {
158
+ clearTimeout(timer)
159
+ resolve('blocked')
160
+ },
161
+ )
162
+ })
163
+
164
+ const dial = (target: EgressDialTarget): Promise<Socket> =>
165
+ new Promise((resolve, reject) => {
166
+ let socket: Socket
167
+ try {
168
+ socket = connect(target)
169
+ } catch (error) {
170
+ reject(error)
171
+ return
172
+ }
173
+ track(socket)
174
+ socket.on('error', () => socket.destroy())
175
+ const timer = setTimeout(() => {
176
+ socket.destroy()
177
+ reject(new Error(`dial to ${target.host}:${target.port} timed out`))
178
+ }, dialTimeoutMs)
179
+ socket.once('close', () => {
180
+ clearTimeout(timer)
181
+ reject(new Error(`dial to ${target.host}:${target.port} failed`))
182
+ })
183
+ socket.once('connect', () => {
184
+ clearTimeout(timer)
185
+ if (closed) {
186
+ socket.destroy()
187
+ return
188
+ }
189
+ resolve(socket)
190
+ })
191
+ })
192
+
193
+ const handleConnect = async (req: IncomingMessage, client: Duplex, head: Buffer): Promise<void> => {
194
+ track(client)
195
+ client.on('error', () => client.destroy())
196
+ let clientLeft = false
197
+ const markLeft = (): void => {
198
+ clientLeft = true
199
+ }
200
+ client.once('end', markLeft)
201
+ client.once('close', markLeft)
202
+ const authority = parseConnectAuthority(req.url ?? '')
203
+ if (!authority) return refuseSocket(client, 400)
204
+ const vetted = await vet(`http://${authority.host}:${authority.port}/`)
205
+ if (vetted === 'timeout') return refuseSocket(client, 504)
206
+ if (vetted === 'blocked') return refuseSocket(client, 403)
207
+ if (closed || clientLeft) {
208
+ client.destroy()
209
+ return
210
+ }
211
+ let upstream: Socket
212
+ try {
213
+ upstream = await dial({ host: dialHost(vetted), port: authority.port, addresses: vetted.addresses })
214
+ } catch {
215
+ return refuseSocket(client, 502)
216
+ }
217
+ if (clientLeft) {
218
+ upstream.destroy()
219
+ client.destroy()
220
+ return
221
+ }
222
+ upstream.setTimeout(idleTimeoutMs, () => {
223
+ upstream.destroy()
224
+ client.destroy()
225
+ })
226
+ upstream.once('close', () => client.destroy())
227
+ client.once('close', () => upstream.destroy())
228
+ client.write('HTTP/1.1 200 Connection Established\r\n\r\n')
229
+ if (head.length > 0) upstream.write(head)
230
+ client.pipe(upstream)
231
+ upstream.pipe(client)
232
+ }
233
+
234
+ const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
235
+ req.on('error', () => res.destroy())
236
+ res.on('error', () => res.destroy())
237
+ const target = parseAbsoluteForm(req.url ?? '')
238
+ if (!target) return refuseResponse(res, 400, 'only absolute-form http:// requests are proxied')
239
+ if (req.headers.upgrade !== undefined) return refuseResponse(res, 400, 'upgrade requests must use CONNECT')
240
+ const vetted = await vet(target.href)
241
+ if (vetted === 'timeout') return refuseResponse(res, 504, 'destination could not be vetted in time')
242
+ if (vetted === 'blocked') return refuseResponse(res, 403, 'destination is not a public address')
243
+ if (closed || res.destroyed) return
244
+ let upstreamSocket: Socket
245
+ try {
246
+ upstreamSocket = await dial({
247
+ host: dialHost(vetted),
248
+ port: Number(vetted.url.port || 80),
249
+ addresses: vetted.addresses,
250
+ })
251
+ } catch {
252
+ return refuseResponse(res, 502, 'destination unreachable')
253
+ }
254
+ if (res.destroyed) {
255
+ upstreamSocket.destroy()
256
+ return
257
+ }
258
+ upstreamSocket.setTimeout(idleTimeoutMs, () => upstreamSocket.destroy())
259
+
260
+ const headers = forwardableHeaders(req.headers)
261
+ headers.host = vetted.url.host
262
+ headers.connection = 'close'
263
+ const forwardResponse = (upstreamResponse: IncomingMessage): void => {
264
+ upstreamResponse.on('error', () => res.destroy())
265
+ const status = upstreamResponse.statusCode ?? 0
266
+ if (status < 200 || status > 999) {
267
+ upstreamResponse.destroy()
268
+ refuseResponse(res, 502, 'upstream sent an invalid status')
269
+ return
270
+ }
271
+ try {
272
+ res.writeHead(status, forwardableHeaders(upstreamResponse.headers))
273
+ } catch {
274
+ upstreamResponse.destroy()
275
+ res.destroy()
276
+ return
277
+ }
278
+ upstreamResponse.pipe(res)
279
+ }
280
+ let upstreamRequest: ClientRequest
281
+ try {
282
+ upstreamRequest = sendHttp(
283
+ {
284
+ method: req.method,
285
+ path: `${vetted.url.pathname}${vetted.url.search}`,
286
+ headers,
287
+ setHost: false,
288
+ createConnection: () => upstreamSocket,
289
+ },
290
+ forwardResponse,
291
+ )
292
+ } catch {
293
+ upstreamSocket.destroy()
294
+ return refuseResponse(res, 502, 'upstream request could not be built')
295
+ }
296
+ upstreamRequest.on('error', () => refuseResponse(res, 502, 'upstream failed'))
297
+ upstreamRequest.on('upgrade', (_response, socket) => {
298
+ socket.destroy()
299
+ refuseResponse(res, 502, 'upstream attempted a protocol upgrade')
300
+ })
301
+ res.once('close', () => upstreamRequest.destroy())
302
+ req.pipe(upstreamRequest)
303
+ }
304
+
305
+ const server = createServer()
306
+ server.timeout = idleTimeoutMs
307
+ server.on('connection', (socket) => track(socket))
308
+ server.on('request', (req, res) => {
309
+ void handleRequest(req, res).catch(() => refuseResponse(res, 502, 'proxy failure'))
310
+ })
311
+ server.on('connect', (req, socket, head) => {
312
+ void handleConnect(req, socket, head).catch(() => socket.destroy())
313
+ })
314
+ server.on('upgrade', (_req, socket) => {
315
+ socket.on('error', () => socket.destroy())
316
+ refuseSocket(socket, 400)
317
+ })
318
+ server.on('clientError', (_error, socket) => {
319
+ refuseSocket(socket, 400)
320
+ })
321
+
322
+ await new Promise<void>((resolve, reject) => {
323
+ server.once('error', reject)
324
+ server.listen(0, '127.0.0.1', () => {
325
+ server.removeListener('error', reject)
326
+ resolve()
327
+ })
328
+ })
329
+ server.on('error', () => undefined)
330
+ const address = server.address()
331
+ if (!address || typeof address === 'string') {
332
+ server.close()
333
+ throw new Error('[internal] egress proxy did not bind a TCP port')
334
+ }
335
+
336
+ return {
337
+ url: `http://127.0.0.1:${address.port}`,
338
+ close: () =>
339
+ new Promise<void>((resolve) => {
340
+ closed = true
341
+ server.close(() => resolve())
342
+ for (const socket of sockets) socket.destroy()
343
+ }),
344
+ }
345
+ }
package/src/playwright.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { assertPublicUrl } from '@open-mercato/web-research'
2
+ import { assertPublicUrl, type LookupFn } from '@open-mercato/web-research'
3
3
  import { SidecarError } from './protocol'
4
4
 
5
5
  /**
@@ -35,8 +35,14 @@ export type BrowserHandle = {
35
35
  close(): Promise<void>
36
36
  }
37
37
 
38
+ export type LaunchOptions = {
39
+ headless?: boolean
40
+ args?: string[]
41
+ proxy?: { server: string; bypass?: string }
42
+ }
43
+
38
44
  export type BrowserType = {
39
- launch(options?: { headless?: boolean; args?: string[] }): Promise<BrowserHandle>
45
+ launch(options?: LaunchOptions): Promise<BrowserHandle>
40
46
  }
41
47
 
42
48
  function isModuleNotFound(message: string): boolean {
@@ -115,7 +121,7 @@ const defaultInstallRunner: InstallRunner = async (onProgress) =>
115
121
  */
116
122
  export async function launchChromium(
117
123
  browserType: BrowserType,
118
- options: { headless?: boolean; args?: string[] },
124
+ options: LaunchOptions,
119
125
  deps: { install?: InstallRunner; onProgress?: (line: string) => void } = {},
120
126
  ): Promise<BrowserHandle> {
121
127
  try {
@@ -150,25 +156,34 @@ export async function launchChromium(
150
156
 
151
157
  const MAX_VERDICT_CACHE = 2048
152
158
 
159
+ export type NavigationGuardOptions = {
160
+ /** @deprecated Every routed request is filtered now; this option is ignored. */
161
+ filterSubresources?: boolean
162
+ warn?: (message: string) => void
163
+ lookup?: LookupFn
164
+ }
165
+
153
166
  /**
154
- * Blocks private-origin navigations for the life of the context.
167
+ * Aborts every routed request whose URL is not public, navigations and
168
+ * subresources alike, for the life of the context.
155
169
  *
156
- * The parent and the sidecar both vet the URL before a `goto`, but a page reached
157
- * through a legitimate public navigation can redirect or script-navigate itself
158
- * to a metadata endpoint, and those hops never pass through the RPC layer.
170
+ * This is an early-abort layer, not the egress boundary: `context.route()` never
171
+ * sees redirect hops, WebSockets, service workers or UDP, and it vets the name
172
+ * with `node:dns` while Chromium dials whatever its own resolver returns. The
173
+ * boundary is the pinned egress proxy every connection is forced through (see
174
+ * `egressProxy.ts`). Navigation verdicts are never cached; subresource verdicts
175
+ * are cached per host for the life of the context.
159
176
  *
160
- * Fails closed: Chromium resolves names with its own resolver, so a hostname
161
- * `node:dns` cannot vet must not be allowed through on the assumption it is dead.
177
+ * Fails closed: a context without request interception is refused, and a URL
178
+ * that cannot be parsed is aborted.
162
179
  */
163
180
  export async function installNavigationGuard(
164
181
  context: ContextHandle,
165
- options: { filterSubresources?: boolean; warn?: (message: string) => void } = {},
182
+ options: NavigationGuardOptions = {},
166
183
  ): Promise<void> {
167
184
  if (typeof context.route !== 'function') {
168
- options.warn?.('context.route() unavailable — in-page navigation SSRF guard NOT installed')
169
- return
185
+ throw new SidecarError('context.route() is unavailable, so the request guard cannot be installed', 'init')
170
186
  }
171
- const filterSubresources = options.filterSubresources ?? false
172
187
  const verdicts = new Map<string, boolean>()
173
188
  const remember = (host: string, blocked: boolean): void => {
174
189
  if (verdicts.size >= MAX_VERDICT_CACHE) {
@@ -181,14 +196,12 @@ export async function installNavigationGuard(
181
196
  await context.route('**/*', async (route) => {
182
197
  const request = route.request()
183
198
  const isNavigation = request.isNavigationRequest()
184
- if (!isNavigation && !filterSubresources) return route.continue()
185
-
186
199
  const url = request.url()
187
200
  let host: string
188
201
  try {
189
202
  host = new URL(url).host
190
203
  } catch {
191
- return isNavigation ? route.abort('blockedbyclient') : route.continue()
204
+ return route.abort('blockedbyclient')
192
205
  }
193
206
 
194
207
  const cached = isNavigation ? undefined : verdicts.get(host)
@@ -198,6 +211,7 @@ export async function installNavigationGuard(
198
211
  try {
199
212
  await assertPublicUrl(url, isNavigation ? 'browser navigation' : 'browser subresource', {
200
213
  failClosed: true,
214
+ ...(options.lookup ? { lookup: options.lookup } : {}),
201
215
  })
202
216
  remember(host, false)
203
217
  } catch {
@@ -208,6 +222,14 @@ export async function installNavigationGuard(
208
222
  })
209
223
  }
210
224
 
225
+ /**
226
+ * Denies Chromium its own DNS, so any path that bypasses the egress proxy by
227
+ * hostname fails instead of resolving. The proxy address is a literal, so it is
228
+ * unaffected. Only meaningful alongside the egress proxy, so it is kept out of
229
+ * {@link LAUNCH_ARGS}.
230
+ */
231
+ export const RESOLVER_LOCKDOWN_ARGS = ['--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1']
232
+
211
233
  /**
212
234
  * Launch flags aimed at not announcing automation. Playwright's defaults set
213
235
  * `navigator.webdriver` and a headless UA, which the SERP endpoints this adapter
@@ -215,12 +237,15 @@ export async function installNavigationGuard(
215
237
  *
216
238
  * `--disable-dev-shm-usage` is a sizing workaround, not a security control: it
217
239
  * makes Chromium write shared memory to /tmp instead of a container's small
218
- * /dev/shm. The sandbox is deliberately NOT disabled here — see
240
+ * /dev/shm. The WebRTC policy and `--disable-quic` keep UDP from leaving outside
241
+ * the egress proxy. The sandbox is deliberately NOT disabled here — see
219
242
  * {@link resolveLaunchArgs}.
220
243
  */
221
244
  export const LAUNCH_ARGS = [
222
245
  '--disable-blink-features=AutomationControlled',
223
246
  '--disable-dev-shm-usage',
247
+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
248
+ '--disable-quic',
224
249
  ]
225
250
 
226
251
  export const SANDBOX_OPT_OUT_ENV = 'OM_WEB_RESEARCH_BROWSER_NO_SANDBOX'