|
1 | 1 | import type { ChildProcess } from 'node:child_process'; |
2 | 2 | import { EventEmitter } from 'node:events'; |
3 | 3 | import { createServer, request } from 'node:http'; |
| 4 | +import { createConnection, createServer as createTcpServer, type Socket } from 'node:net'; |
| 5 | +import { PassThrough, type Duplex } from 'node:stream'; |
4 | 6 |
|
5 | 7 | import { describe, expect, it, vi } from 'vitest'; |
6 | 8 |
|
7 | 9 | import { WEB_CLONE_CDP_METHODS } from '../src/browser-cdp.js'; |
8 | | -import { createBrowserNetworkProxy } from '../src/browser-network-proxy.js'; |
| 10 | +import { |
| 11 | + createBrowserNetworkProxy, |
| 12 | + trackBrowserNetworkTunnel, |
| 13 | +} from '../src/browser-network-proxy.js'; |
9 | 14 | import { assertBrowserNetworkUrl, type BrowserDnsLookup } from '../src/browser-network-policy.js'; |
10 | 15 | import { removeBrowserProfile, terminateBrowserProcess } from '../src/browser-sessions.js'; |
11 | 16 |
|
@@ -57,6 +62,59 @@ describe('Website Clone browser broker security boundary', () => { |
57 | 62 | } |
58 | 63 | }); |
59 | 64 |
|
| 65 | + it.each(['client', 'upstream'] as const)( |
| 66 | + 'contains a normal HTTPS tunnel EPIPE from the %s endpoint and tears down its peer', |
| 67 | + async (source) => { |
| 68 | + const client = new PassThrough(); |
| 69 | + const upstream = new PassThrough(); |
| 70 | + const tunnels = new Set<Duplex>(); |
| 71 | + const clientClosed = new Promise<void>((resolve) => client.once('close', () => resolve())); |
| 72 | + const upstreamClosed = new Promise<void>((resolve) => upstream.once('close', () => resolve())); |
| 73 | + |
| 74 | + trackBrowserNetworkTunnel(client, upstream, tunnels); |
| 75 | + const socketError = Object.assign(new Error('normal HTTPS tunnel teardown'), { code: 'EPIPE' }); |
| 76 | + expect(() => (source === 'client' ? client : upstream).emit('error', socketError)).not.toThrow(); |
| 77 | + |
| 78 | + await Promise.all([clientClosed, upstreamClosed]); |
| 79 | + expect(client.destroyed).toBe(true); |
| 80 | + expect(upstream.destroyed).toBe(true); |
| 81 | + expect(tunnels.size).toBe(0); |
| 82 | + }, |
| 83 | + ); |
| 84 | + |
| 85 | + it('closes an HTTPS CONNECT peer and keeps the proxy available for the next browser tunnel', async () => { |
| 86 | + const targetSockets = new Set<Socket>(); |
| 87 | + const target = createTcpServer((socket) => { |
| 88 | + targetSockets.add(socket); |
| 89 | + socket.on('error', () => undefined); |
| 90 | + socket.once('close', () => targetSockets.delete(socket)); |
| 91 | + }); |
| 92 | + await new Promise<void>((resolve) => target.listen(0, '127.0.0.1', resolve)); |
| 93 | + const targetAddress = target.address(); |
| 94 | + if (!targetAddress || typeof targetAddress === 'string') throw new Error('target did not bind'); |
| 95 | + const proxy = await createBrowserNetworkProxy({ allowPrivateNetwork: true }); |
| 96 | + |
| 97 | + try { |
| 98 | + const first = await connectTunnel(proxy.port, targetAddress.port); |
| 99 | + first.on('error', () => undefined); |
| 100 | + await waitFor(() => targetSockets.size === 1); |
| 101 | + first.destroy(); |
| 102 | + await waitFor(() => targetSockets.size === 0); |
| 103 | + |
| 104 | + // A daemon-fatal uncaughtException would prevent this follow-up tunnel, |
| 105 | + // which represents the next browser session using the same broker. |
| 106 | + const second = await connectTunnel(proxy.port, targetAddress.port); |
| 107 | + second.on('error', () => undefined); |
| 108 | + expect(second.destroyed).toBe(false); |
| 109 | + second.destroy(); |
| 110 | + await waitFor(() => targetSockets.size === 0); |
| 111 | + } finally { |
| 112 | + await proxy.close(); |
| 113 | + for (const socket of targetSockets) socket.destroy(); |
| 114 | + await new Promise<void>((resolve) => target.close(() => resolve())); |
| 115 | + } |
| 116 | + }); |
| 117 | + |
60 | 118 | it('exposes only the CDP methods required by the staged recon adapter', () => { |
61 | 119 | expect(WEB_CLONE_CDP_METHODS).toEqual(new Set([ |
62 | 120 | 'Emulation.setDeviceMetricsOverride', |
@@ -96,6 +154,39 @@ async function requestThroughProxy(proxyPort: number, url: string): Promise<{ bo |
96 | 154 | }); |
97 | 155 | } |
98 | 156 |
|
| 157 | +async function connectTunnel(proxyPort: number, targetPort: number): Promise<Socket> { |
| 158 | + return new Promise((resolve, reject) => { |
| 159 | + const socket = createConnection({ host: '127.0.0.1', port: proxyPort }); |
| 160 | + let response = ''; |
| 161 | + const onError = (error: Error) => reject(error); |
| 162 | + const onData = (chunk: Buffer) => { |
| 163 | + response += chunk.toString('latin1'); |
| 164 | + if (!response.includes('\r\n\r\n')) return; |
| 165 | + socket.off('data', onData); |
| 166 | + socket.off('error', onError); |
| 167 | + if (!response.startsWith('HTTP/1.1 200')) { |
| 168 | + reject(new Error(`CONNECT failed: ${response}`)); |
| 169 | + socket.destroy(); |
| 170 | + return; |
| 171 | + } |
| 172 | + resolve(socket); |
| 173 | + }; |
| 174 | + socket.once('error', onError); |
| 175 | + socket.on('data', onData); |
| 176 | + socket.once('connect', () => { |
| 177 | + socket.write(`CONNECT 127.0.0.1:${targetPort} HTTP/1.1\r\nHost: 127.0.0.1:${targetPort}\r\n\r\n`); |
| 178 | + }); |
| 179 | + }); |
| 180 | +} |
| 181 | + |
| 182 | +async function waitFor(condition: () => boolean, timeoutMs = 1_000): Promise<void> { |
| 183 | + const startedAt = Date.now(); |
| 184 | + while (!condition()) { |
| 185 | + if (Date.now() - startedAt >= timeoutMs) throw new Error('condition timed out'); |
| 186 | + await new Promise((resolve) => setTimeout(resolve, 10)); |
| 187 | + } |
| 188 | +} |
| 189 | + |
99 | 190 | describe('Website Clone browser process cleanup', () => { |
100 | 191 | it('waits for a SIGTERM-resistant browser to exit after SIGKILL before deleting its profile', async () => { |
101 | 192 | const child = new EventEmitter() as EventEmitter & { |
|
0 commit comments