@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/dispatch.js +61 -14
- package/dist/dispatch.js.map +2 -2
- package/dist/egressProxy.js +291 -0
- package/dist/egressProxy.js.map +7 -0
- package/dist/playwright.js +9 -7
- package/dist/playwright.js.map +2 -2
- package/package.json +3 -3
- package/src/__tests__/egressProxy.test.ts +481 -0
- package/src/__tests__/launchWiring.test.ts +222 -0
- package/src/__tests__/navigationGuard.test.ts +200 -0
- package/src/dispatch.ts +79 -15
- package/src/egressProxy.ts +345 -0
- package/src/playwright.ts +42 -17
|
@@ -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?:
|
|
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:
|
|
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
|
-
*
|
|
167
|
+
* Aborts every routed request whose URL is not public, navigations and
|
|
168
|
+
* subresources alike, for the life of the context.
|
|
155
169
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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:
|
|
161
|
-
*
|
|
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:
|
|
182
|
+
options: NavigationGuardOptions = {},
|
|
166
183
|
): Promise<void> {
|
|
167
184
|
if (typeof context.route !== 'function') {
|
|
168
|
-
|
|
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
|
|
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
|
|
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'
|