@crewhaus/computer-use-driver 0.1.4 → 0.1.5

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,341 @@
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
+ const defaultDnsLookup = (host) => dnsLookup(host, { verbatim: false });
43
+ /**
44
+ * Normalise an IPv4 literal to canonical dotted-decimal. Browsers and many
45
+ * HTTP stacks accept octal (`0177.0.0.1`), hex (`0x7f.0.0.1` / `0x7f000001`),
46
+ * and 32-bit integer (`2130706433`) forms — all of which resolve to the same
47
+ * address as `127.0.0.1`. Canonicalising first means `isPrivateIp` classifies
48
+ * them. Returns dotted-decimal, or `null` if `raw` is not an IPv4 literal.
49
+ */
50
+ export function normalizeIpv4(raw) {
51
+ const trimmed = raw.trim();
52
+ if (trimmed === "")
53
+ return null;
54
+ const parseComponent = (s) => {
55
+ if (s === "")
56
+ return null;
57
+ let value;
58
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
59
+ value = Number.parseInt(s.slice(2), 16);
60
+ }
61
+ else if (/^0[0-7]+$/.test(s)) {
62
+ value = Number.parseInt(s, 8);
63
+ }
64
+ else if (/^[0-9]+$/.test(s)) {
65
+ value = Number.parseInt(s, 10);
66
+ }
67
+ else {
68
+ return null;
69
+ }
70
+ return Number.isNaN(value) ? null : value;
71
+ };
72
+ const segments = trimmed.split(".");
73
+ if (segments.length > 4)
74
+ return null;
75
+ const components = [];
76
+ for (const seg of segments) {
77
+ const value = parseComponent(seg);
78
+ if (value === null || value < 0)
79
+ return null;
80
+ components.push(value);
81
+ }
82
+ // RFC-3986-style packing: the final component absorbs the remaining bytes
83
+ // (e.g. `127.1` ⇒ 127.0.0.1, `2130706433` ⇒ 127.0.0.1, `0x7f.0.0.1`).
84
+ const n = components.length;
85
+ const octets = [0, 0, 0, 0];
86
+ for (let i = 0; i < n - 1; i++) {
87
+ const c = components[i];
88
+ if (c > 255)
89
+ return null;
90
+ octets[i] = c;
91
+ }
92
+ const last = components[n - 1];
93
+ const maxLast = 2 ** (8 * (4 - (n - 1)));
94
+ if (last >= maxLast)
95
+ return null;
96
+ let rest = last;
97
+ for (let i = 3; i >= n - 1; i--) {
98
+ octets[i] = rest & 0xff;
99
+ rest = Math.floor(rest / 256);
100
+ }
101
+ return octets.join(".");
102
+ }
103
+ export function isPrivateIp(addr) {
104
+ // Strip IPv6 brackets if present.
105
+ const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
106
+ const lowerIp = ip.toLowerCase();
107
+ // IPv6 unspecified / loopback / link-local / unique-local
108
+ if (lowerIp === "::" || lowerIp === "::1")
109
+ return true;
110
+ if (lowerIp.startsWith("fe80:"))
111
+ return true;
112
+ if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd"))
113
+ return true;
114
+ // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by the
115
+ // embedded IPv4 address so a mapped literal can't smuggle past the checks.
116
+ const mapped = lowerIp.match(/^::ffff:(.+)$/);
117
+ if (mapped) {
118
+ const tail = mapped[1];
119
+ if (tail.includes(".")) {
120
+ return isPrivateIp(tail);
121
+ }
122
+ // Hex form ::ffff:7f00:0001 → 127.0.0.1
123
+ const hexGroups = tail.split(":");
124
+ if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
125
+ const hi = Number.parseInt(hexGroups[0], 16);
126
+ const lo = Number.parseInt(hexGroups[1], 16);
127
+ return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
128
+ }
129
+ return false;
130
+ }
131
+ // IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
132
+ const normalized = normalizeIpv4(ip);
133
+ if (normalized === null)
134
+ return false;
135
+ const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
136
+ if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
137
+ return false;
138
+ }
139
+ const [a, b] = parts;
140
+ if (a === 127)
141
+ return true; // 127.0.0.0/8 loopback
142
+ if (a === 10)
143
+ return true; // 10.0.0.0/8 RFC1918
144
+ if (a === 169 && b === 254)
145
+ return true; // 169.254.0.0/16 link-local + AWS metadata
146
+ if (a === 172 && b >= 16 && b <= 31)
147
+ return true; // 172.16.0.0/12 RFC1918
148
+ if (a === 192 && b === 168)
149
+ return true; // 192.168.0.0/16 RFC1918
150
+ if (a === 100 && b >= 64 && b <= 127)
151
+ return true; // 100.64.0.0/10 CGNAT (RFC6598)
152
+ if (a === 192 && b === 0 && parts[2] === 0)
153
+ return true; // 192.0.0.0/24 IETF protocol (RFC6890)
154
+ if (a === 198 && (b === 18 || b === 19))
155
+ return true; // 198.18.0.0/15 benchmarking (RFC2544)
156
+ if (a === 0)
157
+ return true; // 0.0.0.0/8
158
+ return false;
159
+ }
160
+ class BlockedTargetError extends ComputerUseDriverError {
161
+ }
162
+ /**
163
+ * Resolve `hostname` once and validate the result. Returns the pinned IP the
164
+ * caller must dial — never let anything re-resolve after this.
165
+ */
166
+ async function resolvePinned(hostname, lookupFn, isBlocked) {
167
+ const lower = hostname.toLowerCase();
168
+ if (lower === "localhost" || lower.endsWith(".localhost") || lower.endsWith(".local")) {
169
+ throw new BlockedTargetError(`blocked host "${hostname}": loopback/mDNS name`);
170
+ }
171
+ // An IP literal is its own pinned target — no DNS lookup, nothing to rebind.
172
+ const unbracketed = lower.replace(/^\[/, "").replace(/\]$/, "");
173
+ const literal = normalizeIpv4(unbracketed) ?? (unbracketed.includes(":") ? unbracketed : null);
174
+ if (literal !== null) {
175
+ if (isBlocked(literal)) {
176
+ throw new BlockedTargetError(`blocked target IP ${literal}`);
177
+ }
178
+ return { ip: literal, family: literal.includes(":") ? 6 : 4 };
179
+ }
180
+ let resolved;
181
+ try {
182
+ resolved = await lookupFn(lower);
183
+ }
184
+ catch (err) {
185
+ const msg = err instanceof Error ? err.message : String(err);
186
+ throw new BlockedTargetError(`cannot resolve "${hostname}": ${msg}`);
187
+ }
188
+ if (isBlocked(resolved.address)) {
189
+ throw new BlockedTargetError(`blocked host "${hostname}": resolves to private IP ${resolved.address}`);
190
+ }
191
+ return { ip: resolved.address, family: resolved.family };
192
+ }
193
+ /** Parse a CONNECT target ("host:port", "[::1]:443") into host + port. */
194
+ function parseConnectTarget(target) {
195
+ const m = target.match(/^\[(.+)\]:(\d{1,5})$/) ?? target.match(/^([^:]+):(\d{1,5})$/);
196
+ if (m === null) {
197
+ throw new BlockedTargetError(`malformed CONNECT target "${target}"`);
198
+ }
199
+ const port = Number.parseInt(m[2], 10);
200
+ if (port < 1 || port > 65535) {
201
+ throw new BlockedTargetError(`malformed CONNECT target "${target}": bad port`);
202
+ }
203
+ return { host: m[1], port };
204
+ }
205
+ // Hop-by-hop headers must not be forwarded by a proxy (RFC 7230 §6.1).
206
+ const HOP_BY_HOP = new Set([
207
+ "proxy-connection",
208
+ "proxy-authenticate",
209
+ "proxy-authorization",
210
+ "connection",
211
+ "keep-alive",
212
+ "te",
213
+ "trailer",
214
+ "upgrade",
215
+ ]);
216
+ export async function startSsrfPinningProxy(opts = {}) {
217
+ const lookupFn = opts._lookup ?? defaultDnsLookup;
218
+ const isBlocked = opts._isIpBlocked ?? isPrivateIp;
219
+ const server = http.createServer((req, res) => {
220
+ void handleHttp(req, res);
221
+ });
222
+ async function handleHttp(req, res) {
223
+ // A forward proxy receives absolute-form request targets. Origin-form
224
+ // ("/path") means someone is talking to the proxy as if it were an origin
225
+ // server — reject.
226
+ const rawUrl = req.url ?? "";
227
+ if (!rawUrl.startsWith("http://")) {
228
+ res.writeHead(400, { "content-type": "text/plain" });
229
+ res.end("ssrf-pinning-proxy: absolute-form http:// request target required");
230
+ return;
231
+ }
232
+ let url;
233
+ try {
234
+ url = new URL(rawUrl);
235
+ }
236
+ catch {
237
+ res.writeHead(400, { "content-type": "text/plain" });
238
+ res.end("ssrf-pinning-proxy: malformed request target");
239
+ return;
240
+ }
241
+ let pinned;
242
+ try {
243
+ pinned = await resolvePinned(url.hostname, lookupFn, isBlocked);
244
+ }
245
+ catch (err) {
246
+ res.writeHead(403, { "content-type": "text/plain" });
247
+ res.end(`ssrf-pinning-proxy: ${err instanceof Error ? err.message : String(err)}`);
248
+ return;
249
+ }
250
+ const headers = {};
251
+ for (const [k, v] of Object.entries(req.headers)) {
252
+ if (v === undefined || HOP_BY_HOP.has(k.toLowerCase()))
253
+ continue;
254
+ headers[k] = v;
255
+ }
256
+ // The socket dials the pinned IP; the Host header keeps the original
257
+ // name so virtual hosting on the target still works.
258
+ headers["host"] = url.host;
259
+ const upstream = http.request({
260
+ host: pinned.ip,
261
+ family: pinned.family,
262
+ port: url.port === "" ? 80 : Number.parseInt(url.port, 10),
263
+ method: req.method,
264
+ path: `${url.pathname}${url.search}`,
265
+ headers,
266
+ }, (upstreamRes) => {
267
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
268
+ upstreamRes.pipe(res);
269
+ });
270
+ upstream.on("error", () => {
271
+ if (!res.headersSent) {
272
+ res.writeHead(502, { "content-type": "text/plain" });
273
+ }
274
+ res.end("ssrf-pinning-proxy: upstream connection failed");
275
+ });
276
+ req.pipe(upstream);
277
+ }
278
+ server.on("connect", (req, clientSocket, head) => {
279
+ void handleConnect(req, clientSocket, head);
280
+ });
281
+ async function handleConnect(req, clientSocket, head) {
282
+ // Swallow client-side socket errors (browser may abort tunnels freely).
283
+ clientSocket.on("error", () => {
284
+ clientSocket.destroy();
285
+ });
286
+ let pinned;
287
+ let port;
288
+ try {
289
+ const target = parseConnectTarget(req.url ?? "");
290
+ port = target.port;
291
+ pinned = await resolvePinned(target.host, lookupFn, isBlocked);
292
+ }
293
+ catch (err) {
294
+ const msg = err instanceof Error ? err.message : String(err);
295
+ clientSocket.write(`HTTP/1.1 403 Forbidden\r\ncontent-type: text/plain\r\n\r\nssrf-pinning-proxy: ${msg}\r\n`);
296
+ clientSocket.destroy();
297
+ return;
298
+ }
299
+ const upstream = net.connect({ host: pinned.ip, family: pinned.family, port }, () => {
300
+ clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
301
+ if (head.length > 0)
302
+ upstream.write(head);
303
+ upstream.pipe(clientSocket);
304
+ clientSocket.pipe(upstream);
305
+ });
306
+ upstream.on("error", () => {
307
+ // Before the tunnel is established a 502 is still expressible; after,
308
+ // tearing the socket down is all a proxy can do.
309
+ if (!clientSocket.destroyed && upstream.connecting) {
310
+ clientSocket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n");
311
+ }
312
+ clientSocket.destroy();
313
+ upstream.destroy();
314
+ });
315
+ clientSocket.on("close", () => upstream.destroy());
316
+ }
317
+ await new Promise((resolve, reject) => {
318
+ server.once("error", reject);
319
+ server.listen(0, "127.0.0.1", () => resolve());
320
+ });
321
+ // Don't let a leaked proxy hold the event loop open (e.g. tests that never
322
+ // call disconnect); the chromium backend closes it deterministically.
323
+ server.unref();
324
+ const address = server.address();
325
+ if (address === null || typeof address === "string") {
326
+ server.close();
327
+ throw new ComputerUseDriverError("ssrf-pinning-proxy: failed to bind a loopback port");
328
+ }
329
+ return {
330
+ url: `http://127.0.0.1:${address.port}`,
331
+ port: address.port,
332
+ close() {
333
+ return new Promise((resolve) => {
334
+ // closeAllConnections drops live CONNECT tunnels; close() alone would
335
+ // wait for them forever.
336
+ server.closeAllConnections();
337
+ server.close(() => resolve());
338
+ });
339
+ },
340
+ };
341
+ }
package/package.json CHANGED
@@ -1,18 +1,21 @@
1
1
  {
2
2
  "name": "@crewhaus/computer-use-driver",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Cross-platform mouse/keyboard/screenshot driver — chromium backend (Section 25 BROW)",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5"
16
19
  },
17
20
  "peerDependencies": {
18
21
  "playwright": "*"
@@ -40,5 +43,5 @@
40
43
  "publishConfig": {
41
44
  "access": "public"
42
45
  },
43
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
46
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
44
47
  }