@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.
- package/dist/backends/host.d.ts +43 -0
- package/dist/backends/host.js +75 -0
- package/dist/backends/remote.d.ts +51 -0
- package/dist/backends/remote.js +85 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +7 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +257 -0
- package/dist/ssrf-proxy.d.ts +26 -0
- package/dist/ssrf-proxy.js +341 -0
- package/package.json +9 -6
- package/src/backends/backends.test.ts +0 -396
- package/src/backends/host.ts +0 -96
- package/src/backends/remote.ts +0 -127
- package/src/chromium-import-fail.test.ts +0 -39
- package/src/chromium.test.ts +0 -376
- package/src/errors.ts +0 -8
- package/src/index.test.ts +0 -141
- package/src/index.ts +0 -347
- package/src/ssrf-proxy.test.ts +0 -259
- package/src/ssrf-proxy.ts +0 -376
|
@@ -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.
|
|
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": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
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.
|
|
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": ["
|
|
46
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
44
47
|
}
|