@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.
- 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,222 @@
|
|
|
1
|
+
import { connect as netConnect } from 'node:net'
|
|
2
|
+
import { createState, dispatch, teardown, type SidecarState } from '../dispatch'
|
|
3
|
+
import * as egressProxyModule from '../egressProxy'
|
|
4
|
+
import {
|
|
5
|
+
LAUNCH_ARGS,
|
|
6
|
+
RESOLVER_LOCKDOWN_ARGS,
|
|
7
|
+
type BrowserHandle,
|
|
8
|
+
type BrowserType,
|
|
9
|
+
type ContextHandle,
|
|
10
|
+
type LaunchOptions,
|
|
11
|
+
type RouteHandle,
|
|
12
|
+
} from '../playwright'
|
|
13
|
+
|
|
14
|
+
const FILTER_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
|
|
15
|
+
const PUBLIC_PAGE = 'https://93.184.216.34/'
|
|
16
|
+
|
|
17
|
+
type FakeBrowserType = BrowserType & { launches: LaunchOptions[] }
|
|
18
|
+
|
|
19
|
+
function fakeBrowser(): BrowserHandle {
|
|
20
|
+
const page = {
|
|
21
|
+
goto: async () => undefined,
|
|
22
|
+
content: async () => '<html></html>',
|
|
23
|
+
url: () => PUBLIC_PAGE,
|
|
24
|
+
close: async () => undefined,
|
|
25
|
+
}
|
|
26
|
+
const context: ContextHandle = {
|
|
27
|
+
newPage: async () => page,
|
|
28
|
+
close: async () => undefined,
|
|
29
|
+
route: async (_pattern: string, _handler: (route: RouteHandle) => Promise<void> | void) => undefined,
|
|
30
|
+
}
|
|
31
|
+
return { newContext: async () => context, close: async () => undefined }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fakeBrowserType(launch?: (options: LaunchOptions) => Promise<BrowserHandle>): FakeBrowserType {
|
|
35
|
+
const type: FakeBrowserType = {
|
|
36
|
+
launches: [],
|
|
37
|
+
async launch(options = {}) {
|
|
38
|
+
type.launches.push(options)
|
|
39
|
+
if (launch) return launch(options)
|
|
40
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
41
|
+
return fakeBrowser()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
return type
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(state: SidecarState, leaseId: string) {
|
|
48
|
+
return dispatch(state, { id: `render-${leaseId}`, method: 'render', params: { url: PUBLIC_PAGE, leaseId } })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function proxyAccepts(url: string): Promise<boolean> {
|
|
52
|
+
const { port } = new URL(url)
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
const probe = netConnect({ host: '127.0.0.1', port: Number(port) })
|
|
55
|
+
probe.once('connect', () => {
|
|
56
|
+
probe.destroy()
|
|
57
|
+
resolve(true)
|
|
58
|
+
})
|
|
59
|
+
probe.once('error', () => resolve(false))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('browser launch wiring (#7218)', () => {
|
|
64
|
+
const savedFilter = process.env[FILTER_ENV]
|
|
65
|
+
const states: SidecarState[] = []
|
|
66
|
+
|
|
67
|
+
const stateWith = (overrides: Partial<SidecarState>): SidecarState => {
|
|
68
|
+
const state = createState({ warn: () => undefined, ...overrides })
|
|
69
|
+
states.push(state)
|
|
70
|
+
return state
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
delete process.env[FILTER_ENV]
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
afterEach(async () => {
|
|
78
|
+
jest.restoreAllMocks()
|
|
79
|
+
for (const state of states.splice(0)) await teardown(state)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
if (savedFilter === undefined) delete process.env[FILTER_ENV]
|
|
84
|
+
else process.env[FILTER_ENV] = savedFilter
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('launches Chromium through a live egress proxy with loopback not bypassed and UDP restricted', async () => {
|
|
88
|
+
const browserType = fakeBrowserType()
|
|
89
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
90
|
+
|
|
91
|
+
expect((await render(state, 'lease-1')).ok).toBe(true)
|
|
92
|
+
|
|
93
|
+
expect(browserType.launches).toHaveLength(1)
|
|
94
|
+
const options = browserType.launches[0]
|
|
95
|
+
expect(options?.proxy).toEqual({ server: state.egressProxy?.url, bypass: '<-loopback>' })
|
|
96
|
+
expect(options?.proxy?.server).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
|
|
97
|
+
expect(await proxyAccepts(String(options?.proxy?.server))).toBe(true)
|
|
98
|
+
expect(options?.args).toEqual(
|
|
99
|
+
expect.arrayContaining([
|
|
100
|
+
'--force-webrtc-ip-handling-policy=disable_non_proxied_udp',
|
|
101
|
+
'--disable-quic',
|
|
102
|
+
...RESOLVER_LOCKDOWN_ARGS,
|
|
103
|
+
]),
|
|
104
|
+
)
|
|
105
|
+
expect(LAUNCH_ARGS).not.toEqual(expect.arrayContaining(RESOLVER_LOCKDOWN_ARGS))
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('launches again on the next render after a failed launch', async () => {
|
|
109
|
+
let attempts = 0
|
|
110
|
+
const browserType = fakeBrowserType(async () => {
|
|
111
|
+
attempts += 1
|
|
112
|
+
if (attempts === 1) throw new Error('Target page crashed')
|
|
113
|
+
return fakeBrowser()
|
|
114
|
+
})
|
|
115
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
116
|
+
|
|
117
|
+
expect((await render(state, 'lease-1')).ok).toBe(false)
|
|
118
|
+
expect((await render(state, 'lease-2')).ok).toBe(true)
|
|
119
|
+
|
|
120
|
+
expect(browserType.launches).toHaveLength(2)
|
|
121
|
+
expect(await proxyAccepts(String(state.egressProxy?.url))).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('closes the proxy it tore down, not one a concurrent render started meanwhile', async () => {
|
|
125
|
+
let releaseClose: () => void = () => undefined
|
|
126
|
+
const closing = new Promise<void>((resolve) => {
|
|
127
|
+
releaseClose = resolve
|
|
128
|
+
})
|
|
129
|
+
let launches = 0
|
|
130
|
+
const browserType = fakeBrowserType(async () => {
|
|
131
|
+
launches += 1
|
|
132
|
+
const browser = fakeBrowser()
|
|
133
|
+
if (launches === 1) return { ...browser, close: () => closing }
|
|
134
|
+
return browser
|
|
135
|
+
})
|
|
136
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
137
|
+
await render(state, 'lease-1')
|
|
138
|
+
const firstProxy = String(state.egressProxy?.url)
|
|
139
|
+
|
|
140
|
+
const tearingDown = teardown(state)
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
142
|
+
expect((await render(state, 'lease-2')).ok).toBe(true)
|
|
143
|
+
const secondProxy = String(state.egressProxy?.url)
|
|
144
|
+
releaseClose()
|
|
145
|
+
await tearingDown
|
|
146
|
+
|
|
147
|
+
expect(secondProxy).not.toBe(firstProxy)
|
|
148
|
+
expect(await proxyAccepts(firstProxy)).toBe(false)
|
|
149
|
+
expect(await proxyAccepts(secondProxy)).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('does not launch when the egress proxy cannot start', async () => {
|
|
153
|
+
jest.spyOn(egressProxyModule, 'startEgressProxy').mockRejectedValueOnce(new Error('EADDRNOTAVAIL'))
|
|
154
|
+
const browserType = fakeBrowserType()
|
|
155
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
156
|
+
|
|
157
|
+
const reply = await render(state, 'lease-1')
|
|
158
|
+
|
|
159
|
+
expect(reply.ok).toBe(false)
|
|
160
|
+
expect(browserType.launches).toHaveLength(0)
|
|
161
|
+
expect(state.browser).toBeNull()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('closes the egress proxy when the launch fails', async () => {
|
|
165
|
+
const browserType = fakeBrowserType(async () => {
|
|
166
|
+
throw new Error('Target page crashed')
|
|
167
|
+
})
|
|
168
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
169
|
+
|
|
170
|
+
const reply = await render(state, 'lease-1')
|
|
171
|
+
|
|
172
|
+
expect(reply.ok).toBe(false)
|
|
173
|
+
const server = browserType.launches[0]?.proxy?.server
|
|
174
|
+
expect(server).toBeDefined()
|
|
175
|
+
expect(await proxyAccepts(String(server))).toBe(false)
|
|
176
|
+
expect(state.egressProxy ?? null).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('closes the egress proxy on teardown', async () => {
|
|
180
|
+
const state = stateWith({ loadBrowserType: async () => fakeBrowserType() })
|
|
181
|
+
await render(state, 'lease-1')
|
|
182
|
+
const server = String(state.egressProxy?.url)
|
|
183
|
+
expect(await proxyAccepts(server)).toBe(true)
|
|
184
|
+
|
|
185
|
+
await teardown(state)
|
|
186
|
+
|
|
187
|
+
expect(await proxyAccepts(server)).toBe(false)
|
|
188
|
+
expect(state.egressProxy).toBeNull()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('launches once when two leases open concurrently', async () => {
|
|
192
|
+
const browserType = fakeBrowserType()
|
|
193
|
+
const state = stateWith({ loadBrowserType: async () => browserType })
|
|
194
|
+
|
|
195
|
+
const replies = await Promise.all([render(state, 'lease-a'), render(state, 'lease-b')])
|
|
196
|
+
|
|
197
|
+
expect(replies.map((reply) => reply.ok)).toEqual([true, true])
|
|
198
|
+
expect(browserType.launches).toHaveLength(1)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it.each(['0', '1'])('warns that the filter env var set to %s is ignored', async (value) => {
|
|
202
|
+
process.env[FILTER_ENV] = value
|
|
203
|
+
const warnings: string[] = []
|
|
204
|
+
const state = stateWith({ loadBrowserType: async () => fakeBrowserType(), warn: (line) => warnings.push(line) })
|
|
205
|
+
|
|
206
|
+
await render(state, 'lease-1')
|
|
207
|
+
await render(state, 'lease-2')
|
|
208
|
+
|
|
209
|
+
const notices = warnings.filter((line) => line.includes(FILTER_ENV))
|
|
210
|
+
expect(notices).toHaveLength(1)
|
|
211
|
+
expect(notices[0]).toContain('always on')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('does not warn about the filter env var when it is unset', async () => {
|
|
215
|
+
const warnings: string[] = []
|
|
216
|
+
const state = stateWith({ loadBrowserType: async () => fakeBrowserType(), warn: (line) => warnings.push(line) })
|
|
217
|
+
|
|
218
|
+
await render(state, 'lease-1')
|
|
219
|
+
|
|
220
|
+
expect(warnings.filter((line) => line.includes(FILTER_ENV))).toEqual([])
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { LookupFn } from '@open-mercato/web-research'
|
|
2
|
+
import { createState, dispatch } from '../dispatch'
|
|
3
|
+
import { installNavigationGuard, type BrowserHandle, type ContextHandle, type RouteHandle } from '../playwright'
|
|
4
|
+
import { SidecarError } from '../protocol'
|
|
5
|
+
|
|
6
|
+
type RouteHandler = (route: RouteHandle) => Promise<void> | void
|
|
7
|
+
|
|
8
|
+
type GuardedContext = ContextHandle & {
|
|
9
|
+
handler(): RouteHandler | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const FILTER_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
|
|
13
|
+
const PUBLIC_PAGE = 'https://93.184.216.34/'
|
|
14
|
+
const METADATA = 'http://169.254.169.254/latest/meta-data/'
|
|
15
|
+
const LOOPBACK = 'http://127.0.0.1:5432/'
|
|
16
|
+
|
|
17
|
+
function fakeContext(): GuardedContext {
|
|
18
|
+
let routeHandler: RouteHandler | undefined
|
|
19
|
+
return {
|
|
20
|
+
newPage: async () => ({}),
|
|
21
|
+
close: async () => undefined,
|
|
22
|
+
route: async (_pattern: string, handler: RouteHandler) => {
|
|
23
|
+
routeHandler = handler
|
|
24
|
+
},
|
|
25
|
+
handler: () => routeHandler,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function settle(handler: RouteHandler | undefined, url: string, isNavigation: boolean): Promise<string> {
|
|
30
|
+
if (!handler) throw new Error('[internal] no route handler was installed')
|
|
31
|
+
let outcome = 'pending'
|
|
32
|
+
await handler({
|
|
33
|
+
request: () => ({ url: () => url, isNavigationRequest: () => isNavigation }),
|
|
34
|
+
abort: async () => {
|
|
35
|
+
outcome = 'abort'
|
|
36
|
+
},
|
|
37
|
+
continue: async () => {
|
|
38
|
+
outcome = 'continue'
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
return outcome
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type RenderFixture = {
|
|
45
|
+
browser: BrowserHandle
|
|
46
|
+
context: GuardedContext
|
|
47
|
+
contextOptions: Array<Record<string, unknown> | undefined>
|
|
48
|
+
subresourceOutcomes: string[]
|
|
49
|
+
contentReads: () => number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderFixture(finalUrl: string = PUBLIC_PAGE): RenderFixture {
|
|
53
|
+
const contextOptions: Array<Record<string, unknown> | undefined> = []
|
|
54
|
+
const context = fakeContext()
|
|
55
|
+
const subresourceOutcomes: string[] = []
|
|
56
|
+
let reads = 0
|
|
57
|
+
const page = {
|
|
58
|
+
goto: async () => {
|
|
59
|
+
subresourceOutcomes.push(await settle(context.handler(), METADATA, false))
|
|
60
|
+
},
|
|
61
|
+
content: async () => {
|
|
62
|
+
reads += 1
|
|
63
|
+
return '<html><body>page</body></html>'
|
|
64
|
+
},
|
|
65
|
+
url: () => finalUrl,
|
|
66
|
+
close: async () => undefined,
|
|
67
|
+
}
|
|
68
|
+
context.newPage = async () => page
|
|
69
|
+
const browser: BrowserHandle = {
|
|
70
|
+
newContext: async (options) => {
|
|
71
|
+
contextOptions.push(options)
|
|
72
|
+
return context
|
|
73
|
+
},
|
|
74
|
+
close: async () => undefined,
|
|
75
|
+
}
|
|
76
|
+
return { browser, context, contextOptions, subresourceOutcomes, contentReads: () => reads }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('browser request guard (#7218)', () => {
|
|
80
|
+
const savedFilter = process.env[FILTER_ENV]
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
delete process.env[FILTER_ENV]
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
if (savedFilter === undefined) delete process.env[FILTER_ENV]
|
|
88
|
+
else process.env[FILTER_ENV] = savedFilter
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('still blocks a private navigation and allows a public one', async () => {
|
|
92
|
+
const context = fakeContext()
|
|
93
|
+
await installNavigationGuard(context)
|
|
94
|
+
|
|
95
|
+
expect(await settle(context.handler(), METADATA, true)).toBe('abort')
|
|
96
|
+
expect(await settle(context.handler(), PUBLIC_PAGE, true)).toBe('continue')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('blocks private subresources with default options', async () => {
|
|
100
|
+
const context = fakeContext()
|
|
101
|
+
await installNavigationGuard(context)
|
|
102
|
+
|
|
103
|
+
expect(await settle(context.handler(), METADATA, false)).toBe('abort')
|
|
104
|
+
expect(await settle(context.handler(), LOOPBACK, false)).toBe('abort')
|
|
105
|
+
expect(await settle(context.handler(), 'https://93.184.216.34/app.js', false)).toBe('continue')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('ignores the deprecated filterSubresources: false option', async () => {
|
|
109
|
+
const context = fakeContext()
|
|
110
|
+
await installNavigationGuard(context, { filterSubresources: false })
|
|
111
|
+
|
|
112
|
+
expect(await settle(context.handler(), METADATA, false)).toBe('abort')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('blocks a private subresource of a rendered page in the default sidecar configuration', async () => {
|
|
116
|
+
const fixture = renderFixture()
|
|
117
|
+
const state = createState({ browser: fixture.browser, warn: () => undefined })
|
|
118
|
+
|
|
119
|
+
const reply = await dispatch(state, {
|
|
120
|
+
id: 'render-1',
|
|
121
|
+
method: 'render',
|
|
122
|
+
params: { url: PUBLIC_PAGE, leaseId: 'lease-1' },
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
expect(reply.ok).toBe(true)
|
|
126
|
+
expect(fixture.subresourceOutcomes).toEqual(['abort'])
|
|
127
|
+
expect(fixture.contextOptions[0]).toMatchObject({ serviceWorkers: 'block' })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it.each(['0', '1'])('ignores the filter env var set to %s and still filters subresources', async (value) => {
|
|
131
|
+
process.env[FILTER_ENV] = value
|
|
132
|
+
const fixture = renderFixture()
|
|
133
|
+
const state = createState({ browser: fixture.browser, warn: () => undefined })
|
|
134
|
+
|
|
135
|
+
const reply = await dispatch(state, {
|
|
136
|
+
id: 'render-env',
|
|
137
|
+
method: 'render',
|
|
138
|
+
params: { url: PUBLIC_PAGE, leaseId: 'lease-env' },
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
expect(reply.ok).toBe(true)
|
|
142
|
+
expect(fixture.subresourceOutcomes).toEqual(['abort'])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('refuses a render whose page ended on a non-public URL and returns no html', async () => {
|
|
146
|
+
const fixture = renderFixture('http://127.0.0.1:8080/admin')
|
|
147
|
+
const state = createState({ browser: fixture.browser, warn: () => undefined })
|
|
148
|
+
|
|
149
|
+
const reply = await dispatch(state, {
|
|
150
|
+
id: 'render-final',
|
|
151
|
+
method: 'render',
|
|
152
|
+
params: { url: PUBLIC_PAGE, leaseId: 'lease-final' },
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
expect(reply.ok).toBe(false)
|
|
156
|
+
if (reply.ok) return
|
|
157
|
+
expect(reply.error.kind).toBe('navigation')
|
|
158
|
+
expect(JSON.stringify(reply)).not.toContain('<html')
|
|
159
|
+
expect(fixture.contentReads()).toBe(0)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('aborts a request whose URL cannot be parsed instead of letting it through', async () => {
|
|
163
|
+
const context = fakeContext()
|
|
164
|
+
await installNavigationGuard(context)
|
|
165
|
+
|
|
166
|
+
expect(await settle(context.handler(), 'http://[::1', false)).toBe('abort')
|
|
167
|
+
expect(await settle(context.handler(), 'http://[::1', true)).toBe('abort')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('fails closed when request interception cannot be installed', async () => {
|
|
171
|
+
const context: ContextHandle = { newPage: async () => ({}), close: async () => undefined }
|
|
172
|
+
|
|
173
|
+
const error = await installNavigationGuard(context, { warn: () => undefined }).catch((caught: unknown) => caught)
|
|
174
|
+
|
|
175
|
+
expect(error).toBeInstanceOf(SidecarError)
|
|
176
|
+
expect((error as SidecarError).kind).toBe('init')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('caches a blocked subresource verdict even if the name later resolves publicly', async () => {
|
|
180
|
+
const answers = ['10.0.0.7', '93.184.216.34']
|
|
181
|
+
const lookup: LookupFn = jest.fn(async () => [{ address: answers.shift() ?? '93.184.216.34', family: 4 }])
|
|
182
|
+
const context = fakeContext()
|
|
183
|
+
await installNavigationGuard(context, { lookup })
|
|
184
|
+
|
|
185
|
+
expect(await settle(context.handler(), 'https://flip.test/a.js', false)).toBe('abort')
|
|
186
|
+
expect(await settle(context.handler(), 'https://flip.test/b.js', false)).toBe('abort')
|
|
187
|
+
expect(lookup).toHaveBeenCalledTimes(1)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('re-vets navigations instead of caching them', async () => {
|
|
191
|
+
const answers = ['93.184.216.34', '10.0.0.7']
|
|
192
|
+
const lookup: LookupFn = jest.fn(async () => [{ address: answers.shift() ?? '10.0.0.7', family: 4 }])
|
|
193
|
+
const context = fakeContext()
|
|
194
|
+
await installNavigationGuard(context, { lookup })
|
|
195
|
+
|
|
196
|
+
expect(await settle(context.handler(), 'https://flip.test/', true)).toBe('continue')
|
|
197
|
+
expect(await settle(context.handler(), 'https://flip.test/', true)).toBe('abort')
|
|
198
|
+
expect(lookup).toHaveBeenCalledTimes(2)
|
|
199
|
+
})
|
|
200
|
+
})
|
package/src/dispatch.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { assertPublicUrl } from '@open-mercato/web-research'
|
|
1
|
+
import { assertPublicUrl, type LookupFn } from '@open-mercato/web-research'
|
|
2
|
+
import { startEgressProxy, type EgressConnect, type EgressProxy } from './egressProxy'
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_USER_AGENT,
|
|
5
|
+
RESOLVER_LOCKDOWN_ARGS,
|
|
4
6
|
isSandboxDisabled,
|
|
5
7
|
resolveLaunchArgs,
|
|
6
8
|
importChromium,
|
|
@@ -8,6 +10,7 @@ import {
|
|
|
8
10
|
launchChromium,
|
|
9
11
|
type InstallRunner,
|
|
10
12
|
type BrowserHandle,
|
|
13
|
+
type BrowserType,
|
|
11
14
|
type ContextHandle,
|
|
12
15
|
type PageHandle,
|
|
13
16
|
} from './playwright'
|
|
@@ -27,14 +30,25 @@ export const LEASE_IDLE_TIMEOUT_MS = 5 * 60_000
|
|
|
27
30
|
export type SidecarState = {
|
|
28
31
|
browser: BrowserHandle | null
|
|
29
32
|
leases: Map<string, Lease>
|
|
33
|
+
/** @deprecated Subresource filtering is always on; this field is ignored. */
|
|
30
34
|
filterSubresources: boolean
|
|
31
35
|
warn: (message: string) => void
|
|
32
36
|
/** Injectable so a lease-expiry test does not have to wait five minutes. */
|
|
33
37
|
now: () => number
|
|
34
38
|
/** Injectable so tests never shell out to `npx playwright install`. */
|
|
35
39
|
installRunner?: InstallRunner
|
|
40
|
+
/** Injectable DNS for the URL checks and the egress proxy, so tests never resolve real names. */
|
|
41
|
+
lookup?: LookupFn
|
|
42
|
+
/** Injectable dial for the egress proxy, so tests can stand in for public hosts. */
|
|
43
|
+
connect?: EgressConnect
|
|
44
|
+
/** Injectable so tests never import Playwright. */
|
|
45
|
+
loadBrowserType?: () => Promise<BrowserType>
|
|
46
|
+
egressProxy?: EgressProxy | null
|
|
47
|
+
launching?: Promise<BrowserHandle> | null
|
|
36
48
|
}
|
|
37
49
|
|
|
50
|
+
const FILTER_SUBRESOURCES_ENV = 'OM_WEB_RESEARCH_BROWSER_FILTER_SUBRESOURCES'
|
|
51
|
+
|
|
38
52
|
export const DEFAULT_GOTO_TIMEOUT_MS = 30_000
|
|
39
53
|
const MAX_GOTO_TIMEOUT_MS = 120_000
|
|
40
54
|
|
|
@@ -42,16 +56,19 @@ export function createState(overrides: Partial<SidecarState> = {}): SidecarState
|
|
|
42
56
|
return {
|
|
43
57
|
browser: null,
|
|
44
58
|
leases: new Map(),
|
|
45
|
-
filterSubresources:
|
|
59
|
+
filterSubresources: true,
|
|
46
60
|
warn: (message) => process.stderr.write(`om-web-research-sidecar: ${message}\n`),
|
|
47
61
|
now: () => Date.now(),
|
|
48
62
|
...overrides,
|
|
49
63
|
}
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
function lookupOption(state: SidecarState): { lookup?: LookupFn } {
|
|
67
|
+
return state.lookup ? { lookup: state.lookup } : {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function launchBrowser(state: SidecarState): Promise<BrowserHandle> {
|
|
71
|
+
const browserType = await (state.loadBrowserType ?? importChromium)()
|
|
55
72
|
if (isSandboxDisabled()) {
|
|
56
73
|
// Said once per browser, not once per render, but said: this process renders
|
|
57
74
|
// pages chosen by a search engine or a model with the one mitigation
|
|
@@ -60,12 +77,45 @@ async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
|
|
|
60
77
|
'SECURITY: Chromium sandbox is disabled by OM_WEB_RESEARCH_BROWSER_NO_SANDBOX=1 — a renderer compromise reaches this host',
|
|
61
78
|
)
|
|
62
79
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
if (process.env[FILTER_SUBRESOURCES_ENV] !== undefined) {
|
|
81
|
+
state.warn(`subresource filtering is now always on; ${FILTER_SUBRESOURCES_ENV} is ignored`)
|
|
82
|
+
}
|
|
83
|
+
const proxy = await startEgressProxy({
|
|
84
|
+
...lookupOption(state),
|
|
85
|
+
...(state.connect ? { connect: state.connect } : {}),
|
|
86
|
+
})
|
|
87
|
+
let browser: BrowserHandle
|
|
88
|
+
try {
|
|
89
|
+
browser = await launchChromium(
|
|
90
|
+
browserType,
|
|
91
|
+
{
|
|
92
|
+
headless: true,
|
|
93
|
+
args: [...resolveLaunchArgs(), ...RESOLVER_LOCKDOWN_ARGS],
|
|
94
|
+
proxy: { server: proxy.url, bypass: '<-loopback>' },
|
|
95
|
+
},
|
|
96
|
+
{ onProgress: state.warn, ...(state.installRunner ? { install: state.installRunner } : {}) },
|
|
97
|
+
)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
await proxy.close()
|
|
100
|
+
throw error
|
|
101
|
+
}
|
|
102
|
+
state.egressProxy = proxy
|
|
103
|
+
state.browser = browser
|
|
104
|
+
return browser
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Single-flight: two leases opening at once share one launch, so neither a
|
|
109
|
+
* browser nor its egress proxy is orphaned outside `teardown`.
|
|
110
|
+
*/
|
|
111
|
+
async function ensureBrowser(state: SidecarState): Promise<BrowserHandle> {
|
|
112
|
+
if (state.browser) return state.browser
|
|
113
|
+
if (!state.launching) {
|
|
114
|
+
state.launching = launchBrowser(state).finally(() => {
|
|
115
|
+
state.launching = null
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
return state.launching
|
|
69
119
|
}
|
|
70
120
|
|
|
71
121
|
/**
|
|
@@ -81,8 +131,8 @@ async function openLease(state: SidecarState, leaseId: string, userAgent: string
|
|
|
81
131
|
return existing
|
|
82
132
|
}
|
|
83
133
|
const browser = await ensureBrowser(state)
|
|
84
|
-
const context = await browser.newContext({ userAgent, locale: 'en-US' })
|
|
85
|
-
await installNavigationGuard(context, {
|
|
134
|
+
const context = await browser.newContext({ userAgent, locale: 'en-US', serviceWorkers: 'block' })
|
|
135
|
+
await installNavigationGuard(context, { warn: state.warn, ...lookupOption(state) })
|
|
86
136
|
const page = (await context.newPage()) as unknown as PageHandle
|
|
87
137
|
const lease: Lease = { context, page, touchedAt: state.now() }
|
|
88
138
|
state.leases.set(leaseId, lease)
|
|
@@ -111,9 +161,12 @@ async function closeLease(state: SidecarState, leaseId: string): Promise<void> {
|
|
|
111
161
|
}
|
|
112
162
|
|
|
113
163
|
export async function teardown(state: SidecarState): Promise<void> {
|
|
164
|
+
if (state.launching) await state.launching.catch(() => null)
|
|
114
165
|
for (const leaseId of [...state.leases.keys()]) await closeLease(state, leaseId)
|
|
115
166
|
const browser = state.browser
|
|
167
|
+
const proxy = state.egressProxy
|
|
116
168
|
state.browser = null
|
|
169
|
+
state.egressProxy = null
|
|
117
170
|
if (browser) {
|
|
118
171
|
try {
|
|
119
172
|
await browser.close()
|
|
@@ -121,6 +174,7 @@ export async function teardown(state: SidecarState): Promise<void> {
|
|
|
121
174
|
// Best effort; the parent escalates to SIGKILL if we hang.
|
|
122
175
|
}
|
|
123
176
|
}
|
|
177
|
+
if (proxy) await proxy.close()
|
|
124
178
|
}
|
|
125
179
|
|
|
126
180
|
function stringParam(request: SidecarRequest, key: string): string {
|
|
@@ -142,7 +196,7 @@ async function dispatchInner(state: SidecarState, request: SidecarRequest): Prom
|
|
|
142
196
|
// Re-checked here, in a distinct process from the caller, and BEFORE
|
|
143
197
|
// Playwright is imported — a blocked URL never launches a browser.
|
|
144
198
|
try {
|
|
145
|
-
await assertPublicUrl(url, 'browser render', { failClosed: true })
|
|
199
|
+
await assertPublicUrl(url, 'browser render', { failClosed: true, ...lookupOption(state) })
|
|
146
200
|
} catch (error) {
|
|
147
201
|
return {
|
|
148
202
|
id: request.id,
|
|
@@ -168,8 +222,18 @@ async function dispatchInner(state: SidecarState, request: SidecarRequest): Prom
|
|
|
168
222
|
error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
|
|
169
223
|
}
|
|
170
224
|
}
|
|
225
|
+
const finalUrl = lease.page.url()
|
|
226
|
+
try {
|
|
227
|
+
await assertPublicUrl(finalUrl, 'browser final URL', { failClosed: true, ...lookupOption(state) })
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return {
|
|
230
|
+
id: request.id,
|
|
231
|
+
ok: false,
|
|
232
|
+
error: { message: error instanceof Error ? error.message : String(error), kind: 'navigation' },
|
|
233
|
+
}
|
|
234
|
+
}
|
|
171
235
|
const html = await lease.page.content()
|
|
172
|
-
return { id: request.id, ok: true, result: { url:
|
|
236
|
+
return { id: request.id, ok: true, result: { url: finalUrl, html } }
|
|
173
237
|
}
|
|
174
238
|
|
|
175
239
|
case 'release':
|