Skip to content

Commit ca900d5

Browse files
committed
fix(daemon): close browser HTTP upstreams
1 parent d673bca commit ca900d5

2 files changed

Lines changed: 139 additions & 3 deletions

File tree

apps/daemon/src/browser-network-proxy.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
2-
import net from 'node:net';
1+
import http, { type ClientRequest, type IncomingMessage, type ServerResponse } from 'node:http';
2+
import net, { type Socket } from 'node:net';
33
import type { Duplex } from 'node:stream';
44

55
import {
@@ -57,8 +57,18 @@ export async function createBrowserNetworkProxy(
5757
policy: BrowserNetworkPolicy = {},
5858
): Promise<BrowserNetworkProxy> {
5959
const tunnels = new Set<Duplex>();
60+
const httpRequests = new Set<ClientRequest>();
61+
const httpSockets = new Set<Socket>();
62+
let closing = false;
6063
const server = http.createServer((request, response) => {
61-
void proxyHttpRequest(request, response, policy);
64+
void proxyHttpRequest(
65+
request,
66+
response,
67+
policy,
68+
httpRequests,
69+
httpSockets,
70+
() => closing,
71+
);
6272
});
6373

6474
server.on('connect', (request, client, head) => {
@@ -84,6 +94,11 @@ export async function createBrowserNetworkProxy(
8494
return {
8595
port: address.port,
8696
close: async () => {
97+
closing = true;
98+
for (const request of httpRequests) request.destroy();
99+
for (const socket of httpSockets) socket.destroy();
100+
httpRequests.clear();
101+
httpSockets.clear();
87102
for (const socket of tunnels) socket.destroy();
88103
tunnels.clear();
89104
if (!server.listening) return;
@@ -96,20 +111,53 @@ async function proxyHttpRequest(
96111
request: IncomingMessage,
97112
response: ServerResponse,
98113
policy: BrowserNetworkPolicy,
114+
httpRequests: Set<ClientRequest>,
115+
httpSockets: Set<Socket>,
116+
isClosing: () => boolean,
99117
): Promise<void> {
100118
try {
101119
const target = await resolveBrowserNetworkTarget(requestUrl(request).href, policy);
120+
if (isClosing() || response.destroyed) {
121+
response.destroy();
122+
return;
123+
}
102124
const upstream = http.request({
125+
agent: false,
103126
family: target.family,
104127
headers: upstreamHeaders(request, target.url.host),
105128
host: target.address,
106129
method: request.method,
107130
path: `${target.url.pathname}${target.url.search}`,
108131
port: target.url.port ? Number(target.url.port) : 80,
109132
}, (upstreamResponse) => {
133+
if (response.destroyed) {
134+
upstreamResponse.destroy();
135+
return;
136+
}
110137
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
111138
upstreamResponse.pipe(response);
139+
upstreamResponse.once('error', (error) => {
140+
if (!response.destroyed) response.destroy(error);
141+
});
112142
});
143+
httpRequests.add(upstream);
144+
const destroyUpstream = () => upstream.destroy();
145+
const forgetUpstream = () => {
146+
httpRequests.delete(upstream);
147+
request.off('aborted', destroyUpstream);
148+
request.off('error', destroyUpstream);
149+
response.off('close', destroyUpstream);
150+
response.off('error', destroyUpstream);
151+
};
152+
upstream.once('socket', (socket) => {
153+
httpSockets.add(socket);
154+
socket.once('close', () => httpSockets.delete(socket));
155+
});
156+
upstream.once('close', forgetUpstream);
157+
request.once('aborted', destroyUpstream);
158+
request.once('error', destroyUpstream);
159+
response.once('close', destroyUpstream);
160+
response.once('error', destroyUpstream);
113161
upstream.once('error', (error) => {
114162
if (!response.headersSent) response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
115163
response.end(`browser proxy upstream failed: ${proxyErrorMessage(error)}\n`);

apps/daemon/tests/browser-session-security.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,71 @@ describe('Website Clone browser broker security boundary', () => {
115115
}
116116
});
117117

118+
it('tears down an ordinary HTTP upstream when the browser client disconnects', async () => {
119+
const targetSockets = new Set<Socket>();
120+
const target = createServer((_request, response) => {
121+
response.writeHead(200);
122+
response.write('partial response');
123+
});
124+
target.on('connection', (socket) => {
125+
targetSockets.add(socket);
126+
socket.on('error', () => undefined);
127+
socket.once('close', () => targetSockets.delete(socket));
128+
});
129+
await new Promise<void>((resolve) => target.listen(0, '127.0.0.1', resolve));
130+
const targetAddress = target.address();
131+
if (!targetAddress || typeof targetAddress === 'string') throw new Error('target did not bind');
132+
const proxy = await createBrowserNetworkProxy({ allowPrivateNetwork: true });
133+
const client = openHttpStreamThroughProxy(proxy.port, targetAddress.port);
134+
135+
try {
136+
await client.responseStarted;
137+
expect(targetSockets.size).toBe(1);
138+
client.request.destroy();
139+
await waitFor(() => targetSockets.size === 0);
140+
} finally {
141+
client.request.destroy();
142+
await proxy.close();
143+
for (const socket of targetSockets) socket.destroy();
144+
await new Promise<void>((resolve) => target.close(() => resolve()));
145+
}
146+
});
147+
148+
it('does not retain ordinary HTTP upstreams across repeated proxy lifecycles', async () => {
149+
const targetSockets = new Set<Socket>();
150+
const target = createServer((_request, response) => {
151+
response.writeHead(200);
152+
response.write('partial response');
153+
});
154+
target.on('connection', (socket) => {
155+
targetSockets.add(socket);
156+
socket.on('error', () => undefined);
157+
socket.once('close', () => targetSockets.delete(socket));
158+
});
159+
await new Promise<void>((resolve) => target.listen(0, '127.0.0.1', resolve));
160+
const targetAddress = target.address();
161+
if (!targetAddress || typeof targetAddress === 'string') throw new Error('target did not bind');
162+
163+
try {
164+
for (let iteration = 0; iteration < 3; iteration += 1) {
165+
const proxy = await createBrowserNetworkProxy({ allowPrivateNetwork: true });
166+
const client = openHttpStreamThroughProxy(proxy.port, targetAddress.port);
167+
try {
168+
await client.responseStarted;
169+
expect(targetSockets.size).toBe(1);
170+
await proxy.close();
171+
await waitFor(() => targetSockets.size === 0);
172+
} finally {
173+
client.request.destroy();
174+
await proxy.close();
175+
}
176+
}
177+
} finally {
178+
for (const socket of targetSockets) socket.destroy();
179+
await new Promise<void>((resolve) => target.close(() => resolve()));
180+
}
181+
});
182+
118183
it('exposes only the CDP methods required by the staged recon adapter', () => {
119184
expect(WEB_CLONE_CDP_METHODS).toEqual(new Set([
120185
'Emulation.setDeviceMetricsOverride',
@@ -179,6 +244,29 @@ async function connectTunnel(proxyPort: number, targetPort: number): Promise<Soc
179244
});
180245
}
181246

247+
function openHttpStreamThroughProxy(proxyPort: number, targetPort: number): {
248+
request: ReturnType<typeof request>;
249+
responseStarted: Promise<void>;
250+
} {
251+
let responseStartedResolve: (() => void) | undefined;
252+
let responseStartedReject: ((error: Error) => void) | undefined;
253+
const responseStarted = new Promise<void>((resolve, reject) => {
254+
responseStartedResolve = resolve;
255+
responseStartedReject = reject;
256+
});
257+
const outbound = request({
258+
headers: { host: `127.0.0.1:${targetPort}` },
259+
host: '127.0.0.1',
260+
path: `http://127.0.0.1:${targetPort}/stream`,
261+
port: proxyPort,
262+
}, (response) => {
263+
response.once('data', () => responseStartedResolve?.());
264+
});
265+
outbound.on('error', (error) => responseStartedReject?.(error));
266+
outbound.end();
267+
return { request: outbound, responseStarted };
268+
}
269+
182270
async function waitFor(condition: () => boolean, timeoutMs = 1_000): Promise<void> {
183271
const startedAt = Date.now();
184272
while (!condition()) {

0 commit comments

Comments
 (0)