@crewhaus/computer-use-driver 0.1.0 → 0.1.2

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,259 @@
1
+ /**
2
+ * SECURITY (audit follow-up R1) — DNS-pinning proxy contract tests.
3
+ *
4
+ * Clients are raw `node:net` sockets writing literal HTTP — the most faithful
5
+ * stand-in for a browser talking to a forward proxy, and immune to any HTTP
6
+ * client library rewriting the request target. DNS + the blocked-IP predicate
7
+ * are injected so no test depends on real resolution or real public hosts:
8
+ * the allow-path tests stub the predicate open and point a fake public name
9
+ * at a local target server; the deny-path tests use the REAL predicate.
10
+ */
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
+ import * as http from "node:http";
13
+ import * as net from "node:net";
14
+ import {
15
+ type SsrfPinningProxy,
16
+ isPrivateIp,
17
+ normalizeIpv4,
18
+ startSsrfPinningProxy,
19
+ } from "./ssrf-proxy";
20
+
21
+ const cleanups: Array<() => Promise<void> | void> = [];
22
+ afterEach(async () => {
23
+ while (cleanups.length > 0) {
24
+ await cleanups.pop()?.();
25
+ }
26
+ });
27
+
28
+ function track(proxy: SsrfPinningProxy): SsrfPinningProxy {
29
+ cleanups.push(() => proxy.close());
30
+ return proxy;
31
+ }
32
+
33
+ /** Write `request` to the proxy and return everything received until close. */
34
+ function rawHttp(port: number, request: string): Promise<string> {
35
+ return new Promise((resolve, reject) => {
36
+ const sock = net.connect({ host: "127.0.0.1", port }, () => {
37
+ sock.write(request);
38
+ });
39
+ let buf = "";
40
+ sock.on("data", (d) => {
41
+ buf += d.toString();
42
+ });
43
+ sock.on("close", () => resolve(buf));
44
+ sock.on("error", reject);
45
+ });
46
+ }
47
+
48
+ /** Local HTTP target that records the request line + Host header. */
49
+ async function startTarget(): Promise<{
50
+ port: number;
51
+ seen: Array<{ url: string; host: string | undefined }>;
52
+ }> {
53
+ const seen: Array<{ url: string; host: string | undefined }> = [];
54
+ const server = http.createServer((req, res) => {
55
+ seen.push({ url: req.url ?? "", host: req.headers.host });
56
+ res.writeHead(200, { "content-type": "text/plain" });
57
+ res.end("target-says-hello");
58
+ });
59
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
60
+ cleanups.push(
61
+ () =>
62
+ new Promise<void>((r) => {
63
+ server.closeAllConnections();
64
+ server.close(() => r());
65
+ }),
66
+ );
67
+ const addr = server.address();
68
+ if (addr === null || typeof addr === "string") throw new Error("no port");
69
+ return { port: addr.port, seen };
70
+ }
71
+
72
+ describe("IP validators (duplicated from tool-fetch — keep in sync)", () => {
73
+ test.each([
74
+ ["127.0.0.1", true],
75
+ ["10.1.2.3", true],
76
+ ["169.254.169.254", true],
77
+ ["192.168.0.1", true],
78
+ ["100.64.0.1", true],
79
+ ["::1", true],
80
+ ["fe80::1", true],
81
+ ["fd00::1", true],
82
+ ["::ffff:127.0.0.1", true],
83
+ ["::ffff:7f00:1", true],
84
+ ["8.8.8.8", false],
85
+ ["93.184.216.34", false],
86
+ ["2606:4700::1111", false],
87
+ ])("isPrivateIp(%p) === %p", (ip, want) => {
88
+ expect(isPrivateIp(ip)).toBe(want);
89
+ });
90
+
91
+ test.each([
92
+ ["0x7f000001", "127.0.0.1"],
93
+ ["2130706433", "127.0.0.1"],
94
+ ["0177.0.0.1", "127.0.0.1"],
95
+ ["127.1", "127.0.0.1"],
96
+ ["not-an-ip", null],
97
+ ])("normalizeIpv4(%p) === %p", (raw, want) => {
98
+ expect(normalizeIpv4(raw)).toBe(want);
99
+ });
100
+ });
101
+
102
+ describe("plain-HTTP forwarding (absolute-form)", () => {
103
+ test("forwards to the PINNED ip, preserving the Host header for vhosts", async () => {
104
+ const target = await startTarget();
105
+ let lookups = 0;
106
+ const proxy = track(
107
+ await startSsrfPinningProxy({
108
+ _lookup: async () => {
109
+ lookups += 1;
110
+ return { address: "127.0.0.1", family: 4 };
111
+ },
112
+ _isIpBlocked: () => false,
113
+ }),
114
+ );
115
+ const res = await rawHttp(
116
+ proxy.port,
117
+ `GET http://fake-public.example:${target.port}/x?q=1 HTTP/1.1\r\nHost: fake-public.example:${target.port}\r\nConnection: close\r\n\r\n`,
118
+ );
119
+ expect(res).toContain("200");
120
+ expect(res).toContain("target-says-hello");
121
+ // The socket went to the pinned 127.0.0.1; the Host header kept the name.
122
+ expect(target.seen).toEqual([{ url: "/x?q=1", host: `fake-public.example:${target.port}` }]);
123
+ // Pinning = exactly one resolution per connection. A second lookup would
124
+ // be the rebinding window this proxy exists to remove.
125
+ expect(lookups).toBe(1);
126
+ });
127
+
128
+ test("origin-form request targets are rejected (not an origin server)", async () => {
129
+ const proxy = track(await startSsrfPinningProxy());
130
+ const res = await rawHttp(
131
+ proxy.port,
132
+ "GET /just-a-path HTTP/1.1\r\nHost: whatever\r\nConnection: close\r\n\r\n",
133
+ );
134
+ expect(res).toContain("400");
135
+ });
136
+
137
+ test("blocks localhost names without resolving", async () => {
138
+ let lookups = 0;
139
+ const proxy = track(
140
+ await startSsrfPinningProxy({
141
+ _lookup: async () => {
142
+ lookups += 1;
143
+ return { address: "8.8.8.8", family: 4 };
144
+ },
145
+ }),
146
+ );
147
+ const res = await rawHttp(
148
+ proxy.port,
149
+ "GET http://localhost:9999/ HTTP/1.1\r\nHost: localhost:9999\r\nConnection: close\r\n\r\n",
150
+ );
151
+ expect(res).toContain("403");
152
+ expect(lookups).toBe(0);
153
+ });
154
+
155
+ test("blocks private IP literals, including obfuscated forms", async () => {
156
+ const proxy = track(await startSsrfPinningProxy());
157
+ for (const host of ["127.0.0.1", "0x7f000001", "169.254.169.254", "[::1]"]) {
158
+ const res = await rawHttp(
159
+ proxy.port,
160
+ `GET http://${host}/ HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`,
161
+ );
162
+ expect(res).toContain("403");
163
+ }
164
+ });
165
+
166
+ test("blocks a public name that RESOLVES to a private IP (rebinding shape)", async () => {
167
+ const proxy = track(
168
+ await startSsrfPinningProxy({
169
+ _lookup: async () => ({ address: "169.254.169.254", family: 4 }),
170
+ }),
171
+ );
172
+ const res = await rawHttp(
173
+ proxy.port,
174
+ "GET http://innocent-looking.example/ HTTP/1.1\r\nHost: innocent-looking.example\r\nConnection: close\r\n\r\n",
175
+ );
176
+ expect(res).toContain("403");
177
+ expect(res).toContain("private IP 169.254.169.254");
178
+ });
179
+ });
180
+
181
+ describe("CONNECT tunneling", () => {
182
+ test("tunnels to the PINNED ip and pipes both directions", async () => {
183
+ // TCP echo target.
184
+ const echo = net.createServer((s) => s.pipe(s));
185
+ await new Promise<void>((r) => echo.listen(0, "127.0.0.1", () => r()));
186
+ cleanups.push(() => new Promise<void>((r) => echo.close(() => r())));
187
+ const echoAddr = echo.address();
188
+ if (echoAddr === null || typeof echoAddr === "string") throw new Error("no port");
189
+
190
+ let lookups = 0;
191
+ const proxy = track(
192
+ await startSsrfPinningProxy({
193
+ _lookup: async () => {
194
+ lookups += 1;
195
+ return { address: "127.0.0.1", family: 4 };
196
+ },
197
+ _isIpBlocked: () => false,
198
+ }),
199
+ );
200
+
201
+ const result = await new Promise<string>((resolve, reject) => {
202
+ const sock = net.connect({ host: "127.0.0.1", port: proxy.port }, () => {
203
+ sock.write(
204
+ `CONNECT fake-tls.example:${echoAddr.port} HTTP/1.1\r\nHost: fake-tls.example:${echoAddr.port}\r\n\r\n`,
205
+ );
206
+ });
207
+ let buf = "";
208
+ let established = false;
209
+ sock.on("data", (d) => {
210
+ buf += d.toString();
211
+ if (!established && buf.includes("\r\n\r\n")) {
212
+ if (!buf.startsWith("HTTP/1.1 200")) {
213
+ reject(new Error(`tunnel refused: ${buf.split("\r\n")[0]}`));
214
+ return;
215
+ }
216
+ established = true;
217
+ buf = "";
218
+ sock.write("opaque-tls-bytes");
219
+ } else if (established && buf.includes("opaque-tls-bytes")) {
220
+ sock.end();
221
+ resolve(buf);
222
+ }
223
+ });
224
+ sock.on("error", reject);
225
+ });
226
+ expect(result).toBe("opaque-tls-bytes");
227
+ expect(lookups).toBe(1);
228
+ });
229
+
230
+ test("refuses CONNECT to private targets with 403", async () => {
231
+ const proxy = track(await startSsrfPinningProxy());
232
+ const res = await rawHttp(
233
+ proxy.port,
234
+ "CONNECT 127.0.0.1:443 HTTP/1.1\r\nHost: 127.0.0.1:443\r\n\r\n",
235
+ );
236
+ expect(res).toContain("403");
237
+ });
238
+
239
+ test("refuses malformed CONNECT targets", async () => {
240
+ const proxy = track(await startSsrfPinningProxy());
241
+ const res = await rawHttp(proxy.port, "CONNECT garbage HTTP/1.1\r\nHost: garbage\r\n\r\n");
242
+ expect(res).toContain("403");
243
+ });
244
+ });
245
+
246
+ describe("lifecycle", () => {
247
+ test("close() actually stops accepting connections", async () => {
248
+ const proxy = await startSsrfPinningProxy();
249
+ await proxy.close();
250
+ await expect(
251
+ new Promise((resolve, reject) => {
252
+ const sock = net.connect({ host: "127.0.0.1", port: proxy.port }, () =>
253
+ resolve("connected"),
254
+ );
255
+ sock.on("error", reject);
256
+ }),
257
+ ).rejects.toThrow();
258
+ });
259
+ });
@@ -0,0 +1,376 @@
1
+ /**
2
+ * SECURITY — DNS-pinning egress proxy for the chromium backend (Section 25
3
+ * BROW, audit follow-up R1).
4
+ *
5
+ * The Navigate tool's pre-goto guard (`assertSafeNavigationTarget` in
6
+ * tool-navigate) resolves a hostname, validates the IP, and only then lets the
7
+ * browser navigate — but the browser RE-RESOLVES DNS at connect time. A
8
+ * hostile resolver can answer with a public IP for the check and a private one
9
+ * (127.0.0.1, 169.254.169.254, …) milliseconds later for the socket: the
10
+ * classic DNS-rebinding TOCTOU. The same gap applies to every sub-resource an
11
+ * attacker-controlled page fetches — those never pass the pre-goto guard at
12
+ * all.
13
+ *
14
+ * This module closes the gap at the connection layer. `startSsrfPinningProxy`
15
+ * runs a loopback-only HTTP forward proxy; the chromium backend launches the
16
+ * browser with `proxy: { server, bypass: "<-loopback>" }` so EVERY request the
17
+ * browser makes — navigations, sub-resources, redirects, websockets — arrives
18
+ * here as either an absolute-form HTTP request or a CONNECT tunnel. For each
19
+ * connection the proxy resolves the hostname ONCE, validates the resolved IP
20
+ * against the private/loopback/link-local/metadata floor, and dials that exact
21
+ * pinned IP. The browser never resolves DNS for proxied traffic, so there is
22
+ * nothing left to rebind. TLS stays end-to-end through the CONNECT tunnel —
23
+ * SNI and certificate validation are untouched.
24
+ *
25
+ * The `bypass: "<-loopback>"` rule matters: Chromium implicitly BYPASSES a
26
+ * configured proxy for localhost/loopback targets, which would let an
27
+ * attacker page fetch http://127.0.0.1:… directly. `<-loopback>` removes that
28
+ * implicit bypass so loopback targets also route here — and get blocked.
29
+ *
30
+ * The IP-validation helpers mirror tool-fetch's `assertNotSsrf` family —
31
+ * duplicated (like tool-navigate and crawler) rather than extracted to a
32
+ * shared package, per the Pillar 3 convention; keep the copies in sync.
33
+ *
34
+ * The proxy binds 127.0.0.1 on an ephemeral port and refuses private targets,
35
+ * so it cannot be leveraged by other local processes to reach internal
36
+ * services.
37
+ */
38
+ import { lookup as dnsLookup } from "node:dns/promises";
39
+ import * as http from "node:http";
40
+ import * as net from "node:net";
41
+ import { ComputerUseDriverError } from "./errors";
42
+
43
+ export type DnsLookupFn = (
44
+ host: string,
45
+ ) => Promise<{ readonly address: string; readonly family: number }>;
46
+
47
+ const defaultDnsLookup: DnsLookupFn = (host) => dnsLookup(host, { verbatim: false });
48
+
49
+ export type SsrfPinningProxy = {
50
+ /** Proxy URL for the browser's launch options, e.g. `http://127.0.0.1:49321`. */
51
+ readonly url: string;
52
+ readonly port: number;
53
+ close(): Promise<void>;
54
+ };
55
+
56
+ export type StartSsrfPinningProxyOptions = {
57
+ /** Test seam: replaces the DNS resolver. */
58
+ readonly _lookup?: DnsLookupFn;
59
+ /** Test seam: replaces the blocked-IP predicate. */
60
+ readonly _isIpBlocked?: (ip: string) => boolean;
61
+ };
62
+
63
+ /**
64
+ * Normalise an IPv4 literal to canonical dotted-decimal. Browsers and many
65
+ * HTTP stacks accept octal (`0177.0.0.1`), hex (`0x7f.0.0.1` / `0x7f000001`),
66
+ * and 32-bit integer (`2130706433`) forms — all of which resolve to the same
67
+ * address as `127.0.0.1`. Canonicalising first means `isPrivateIp` classifies
68
+ * them. Returns dotted-decimal, or `null` if `raw` is not an IPv4 literal.
69
+ */
70
+ export function normalizeIpv4(raw: string): string | null {
71
+ const trimmed = raw.trim();
72
+ if (trimmed === "") return null;
73
+
74
+ const parseComponent = (s: string): number | null => {
75
+ if (s === "") return null;
76
+ let value: number;
77
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
78
+ value = Number.parseInt(s.slice(2), 16);
79
+ } else if (/^0[0-7]+$/.test(s)) {
80
+ value = Number.parseInt(s, 8);
81
+ } else if (/^[0-9]+$/.test(s)) {
82
+ value = Number.parseInt(s, 10);
83
+ } else {
84
+ return null;
85
+ }
86
+ return Number.isNaN(value) ? null : value;
87
+ };
88
+
89
+ const segments = trimmed.split(".");
90
+ if (segments.length > 4) return null;
91
+
92
+ const components: number[] = [];
93
+ for (const seg of segments) {
94
+ const value = parseComponent(seg);
95
+ if (value === null || value < 0) return null;
96
+ components.push(value);
97
+ }
98
+
99
+ // RFC-3986-style packing: the final component absorbs the remaining bytes
100
+ // (e.g. `127.1` ⇒ 127.0.0.1, `2130706433` ⇒ 127.0.0.1, `0x7f.0.0.1`).
101
+ const n = components.length;
102
+ const octets = [0, 0, 0, 0];
103
+ for (let i = 0; i < n - 1; i++) {
104
+ const c = components[i] as number;
105
+ if (c > 255) return null;
106
+ octets[i] = c;
107
+ }
108
+ const last = components[n - 1] as number;
109
+ const maxLast = 2 ** (8 * (4 - (n - 1)));
110
+ if (last >= maxLast) return null;
111
+ let rest = last;
112
+ for (let i = 3; i >= n - 1; i--) {
113
+ octets[i] = rest & 0xff;
114
+ rest = Math.floor(rest / 256);
115
+ }
116
+ return octets.join(".");
117
+ }
118
+
119
+ export function isPrivateIp(addr: string): boolean {
120
+ // Strip IPv6 brackets if present.
121
+ const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
122
+ const lowerIp = ip.toLowerCase();
123
+
124
+ // IPv6 unspecified / loopback / link-local / unique-local
125
+ if (lowerIp === "::" || lowerIp === "::1") return true;
126
+ if (lowerIp.startsWith("fe80:")) return true;
127
+ if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd")) return true;
128
+
129
+ // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by the
130
+ // embedded IPv4 address so a mapped literal can't smuggle past the checks.
131
+ const mapped = lowerIp.match(/^::ffff:(.+)$/);
132
+ if (mapped) {
133
+ const tail = mapped[1] as string;
134
+ if (tail.includes(".")) {
135
+ return isPrivateIp(tail);
136
+ }
137
+ // Hex form ::ffff:7f00:0001 → 127.0.0.1
138
+ const hexGroups = tail.split(":");
139
+ if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
140
+ const hi = Number.parseInt(hexGroups[0] as string, 16);
141
+ const lo = Number.parseInt(hexGroups[1] as string, 16);
142
+ return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
143
+ }
144
+ return false;
145
+ }
146
+
147
+ // IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
148
+ const normalized = normalizeIpv4(ip);
149
+ if (normalized === null) return false;
150
+ const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
151
+ if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
152
+ return false;
153
+ }
154
+ const [a, b] = parts as [number, number, number, number];
155
+
156
+ if (a === 127) return true; // 127.0.0.0/8 loopback
157
+ if (a === 10) return true; // 10.0.0.0/8 RFC1918
158
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata
159
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
160
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
161
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (RFC6598)
162
+ if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0.0/24 IETF protocol (RFC6890)
163
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking (RFC2544)
164
+ if (a === 0) return true; // 0.0.0.0/8
165
+ return false;
166
+ }
167
+
168
+ class BlockedTargetError extends ComputerUseDriverError {}
169
+
170
+ /**
171
+ * Resolve `hostname` once and validate the result. Returns the pinned IP the
172
+ * caller must dial — never let anything re-resolve after this.
173
+ */
174
+ async function resolvePinned(
175
+ hostname: string,
176
+ lookupFn: DnsLookupFn,
177
+ isBlocked: (ip: string) => boolean,
178
+ ): Promise<{ readonly ip: string; readonly family: number }> {
179
+ const lower = hostname.toLowerCase();
180
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
181
+ throw new BlockedTargetError(`blocked host "${hostname}": loopback/mDNS name`);
182
+ }
183
+
184
+ // An IP literal is its own pinned target — no DNS lookup, nothing to rebind.
185
+ const unbracketed = lower.replace(/^\[/, "").replace(/\]$/, "");
186
+ const literal = normalizeIpv4(unbracketed) ?? (unbracketed.includes(":") ? unbracketed : null);
187
+ if (literal !== null) {
188
+ if (isBlocked(literal)) {
189
+ throw new BlockedTargetError(`blocked target IP ${literal}`);
190
+ }
191
+ return { ip: literal, family: literal.includes(":") ? 6 : 4 };
192
+ }
193
+
194
+ let resolved: { readonly address: string; readonly family: number };
195
+ try {
196
+ resolved = await lookupFn(lower);
197
+ } catch (err) {
198
+ const msg = err instanceof Error ? err.message : String(err);
199
+ throw new BlockedTargetError(`cannot resolve "${hostname}": ${msg}`);
200
+ }
201
+ if (isBlocked(resolved.address)) {
202
+ throw new BlockedTargetError(
203
+ `blocked host "${hostname}": resolves to private IP ${resolved.address}`,
204
+ );
205
+ }
206
+ return { ip: resolved.address, family: resolved.family };
207
+ }
208
+
209
+ /** Parse a CONNECT target ("host:port", "[::1]:443") into host + port. */
210
+ function parseConnectTarget(target: string): { readonly host: string; readonly port: number } {
211
+ const m = target.match(/^\[(.+)\]:(\d{1,5})$/) ?? target.match(/^([^:]+):(\d{1,5})$/);
212
+ if (m === null) {
213
+ throw new BlockedTargetError(`malformed CONNECT target "${target}"`);
214
+ }
215
+ const port = Number.parseInt(m[2] as string, 10);
216
+ if (port < 1 || port > 65535) {
217
+ throw new BlockedTargetError(`malformed CONNECT target "${target}": bad port`);
218
+ }
219
+ return { host: m[1] as string, port };
220
+ }
221
+
222
+ // Hop-by-hop headers must not be forwarded by a proxy (RFC 7230 §6.1).
223
+ const HOP_BY_HOP = new Set([
224
+ "proxy-connection",
225
+ "proxy-authenticate",
226
+ "proxy-authorization",
227
+ "connection",
228
+ "keep-alive",
229
+ "te",
230
+ "trailer",
231
+ "upgrade",
232
+ ]);
233
+
234
+ export async function startSsrfPinningProxy(
235
+ opts: StartSsrfPinningProxyOptions = {},
236
+ ): Promise<SsrfPinningProxy> {
237
+ const lookupFn = opts._lookup ?? defaultDnsLookup;
238
+ const isBlocked = opts._isIpBlocked ?? isPrivateIp;
239
+
240
+ const server = http.createServer((req, res) => {
241
+ void handleHttp(req, res);
242
+ });
243
+
244
+ async function handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
245
+ // A forward proxy receives absolute-form request targets. Origin-form
246
+ // ("/path") means someone is talking to the proxy as if it were an origin
247
+ // server — reject.
248
+ const rawUrl = req.url ?? "";
249
+ if (!rawUrl.startsWith("http://")) {
250
+ res.writeHead(400, { "content-type": "text/plain" });
251
+ res.end("ssrf-pinning-proxy: absolute-form http:// request target required");
252
+ return;
253
+ }
254
+ let url: URL;
255
+ try {
256
+ url = new URL(rawUrl);
257
+ } catch {
258
+ res.writeHead(400, { "content-type": "text/plain" });
259
+ res.end("ssrf-pinning-proxy: malformed request target");
260
+ return;
261
+ }
262
+
263
+ let pinned: { readonly ip: string; readonly family: number };
264
+ try {
265
+ pinned = await resolvePinned(url.hostname, lookupFn, isBlocked);
266
+ } catch (err) {
267
+ res.writeHead(403, { "content-type": "text/plain" });
268
+ res.end(`ssrf-pinning-proxy: ${err instanceof Error ? err.message : String(err)}`);
269
+ return;
270
+ }
271
+
272
+ const headers: Record<string, string | string[]> = {};
273
+ for (const [k, v] of Object.entries(req.headers)) {
274
+ if (v === undefined || HOP_BY_HOP.has(k.toLowerCase())) continue;
275
+ headers[k] = v;
276
+ }
277
+ // The socket dials the pinned IP; the Host header keeps the original
278
+ // name so virtual hosting on the target still works.
279
+ headers["host"] = url.host;
280
+
281
+ const upstream = http.request(
282
+ {
283
+ host: pinned.ip,
284
+ family: pinned.family,
285
+ port: url.port === "" ? 80 : Number.parseInt(url.port, 10),
286
+ method: req.method,
287
+ path: `${url.pathname}${url.search}`,
288
+ headers,
289
+ },
290
+ (upstreamRes) => {
291
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
292
+ upstreamRes.pipe(res);
293
+ },
294
+ );
295
+ upstream.on("error", () => {
296
+ if (!res.headersSent) {
297
+ res.writeHead(502, { "content-type": "text/plain" });
298
+ }
299
+ res.end("ssrf-pinning-proxy: upstream connection failed");
300
+ });
301
+ req.pipe(upstream);
302
+ }
303
+
304
+ server.on("connect", (req, clientSocket: net.Socket, head: Buffer) => {
305
+ void handleConnect(req, clientSocket, head);
306
+ });
307
+
308
+ async function handleConnect(
309
+ req: http.IncomingMessage,
310
+ clientSocket: net.Socket,
311
+ head: Buffer,
312
+ ): Promise<void> {
313
+ // Swallow client-side socket errors (browser may abort tunnels freely).
314
+ clientSocket.on("error", () => {
315
+ clientSocket.destroy();
316
+ });
317
+ let pinned: { readonly ip: string; readonly family: number };
318
+ let port: number;
319
+ try {
320
+ const target = parseConnectTarget(req.url ?? "");
321
+ port = target.port;
322
+ pinned = await resolvePinned(target.host, lookupFn, isBlocked);
323
+ } catch (err) {
324
+ const msg = err instanceof Error ? err.message : String(err);
325
+ clientSocket.write(
326
+ `HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nssrf-pinning-proxy: ${msg}\r\n`,
327
+ );
328
+ clientSocket.destroy();
329
+ return;
330
+ }
331
+
332
+ const upstream = net.connect({ host: pinned.ip, family: pinned.family, port }, () => {
333
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
334
+ if (head.length > 0) upstream.write(head);
335
+ upstream.pipe(clientSocket);
336
+ clientSocket.pipe(upstream);
337
+ });
338
+ upstream.on("error", () => {
339
+ // Before the tunnel is established a 502 is still expressible; after,
340
+ // tearing the socket down is all a proxy can do.
341
+ if (!clientSocket.destroyed && upstream.connecting) {
342
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
343
+ }
344
+ clientSocket.destroy();
345
+ upstream.destroy();
346
+ });
347
+ clientSocket.on("close", () => upstream.destroy());
348
+ }
349
+
350
+ await new Promise<void>((resolve, reject) => {
351
+ server.once("error", reject);
352
+ server.listen(0, "127.0.0.1", () => resolve());
353
+ });
354
+ // Don't let a leaked proxy hold the event loop open (e.g. tests that never
355
+ // call disconnect); the chromium backend closes it deterministically.
356
+ server.unref();
357
+
358
+ const address = server.address();
359
+ if (address === null || typeof address === "string") {
360
+ server.close();
361
+ throw new ComputerUseDriverError("ssrf-pinning-proxy: failed to bind a loopback port");
362
+ }
363
+
364
+ return {
365
+ url: `http://127.0.0.1:${address.port}`,
366
+ port: address.port,
367
+ close(): Promise<void> {
368
+ return new Promise((resolve) => {
369
+ // closeAllConnections drops live CONNECT tunnels; close() alone would
370
+ // wait for them forever.
371
+ server.closeAllConnections();
372
+ server.close(() => resolve());
373
+ });
374
+ },
375
+ };
376
+ }