@crewhaus/computer-use-driver 0.1.3 → 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
package/src/index.ts
DELETED
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Catalog R18 `computer-use-driver` — Section 25 BROW.
|
|
3
|
-
*
|
|
4
|
-
* Cross-platform driver for mouse/keyboard/screenshot operations. The
|
|
5
|
-
* `Driver` interface exposes:
|
|
6
|
-
* - `screenshot()` → PNG `Uint8Array`
|
|
7
|
-
* - `click(x, y, button?)`
|
|
8
|
-
* - `type(text)`
|
|
9
|
-
* - `key(combo)` // "Enter", "Tab", "Control+a"
|
|
10
|
-
* - `scroll(dx, dy)`
|
|
11
|
-
* - `getViewport()` → { width, height, devicePixelRatio }
|
|
12
|
-
* - `goto(url)` // browser-only; `host` backend rejects
|
|
13
|
-
*
|
|
14
|
-
* v0 ships ONE concrete backend — `chromium` — driven by Playwright's
|
|
15
|
-
* bundled headless chromium. The kickoff names this `docker-chromium`
|
|
16
|
-
* because it's intended to run inside a Section-18 sandbox; in v0 we
|
|
17
|
-
* lean on Playwright's own chromium-sandboxing (the chromium binary
|
|
18
|
-
* runs as a sandboxed renderer process) and defer the explicit Docker
|
|
19
|
-
* wrapping to a follow-up.
|
|
20
|
-
*
|
|
21
|
-
* The `host` backend (drives the developer's actual desktop) and
|
|
22
|
-
* `remote` backend (CDP-style remote control) are interface stubs that
|
|
23
|
-
* throw at `connect()` — see the kickoff explicitly forbidding the host
|
|
24
|
-
* backend in the smoke ("never use `host` backend for the smoke since
|
|
25
|
-
* it would drive the dev's actual desktop").
|
|
26
|
-
*/
|
|
27
|
-
import { ComputerUseDriverError } from "./errors";
|
|
28
|
-
import { type SsrfPinningProxy, startSsrfPinningProxy } from "./ssrf-proxy";
|
|
29
|
-
|
|
30
|
-
export { ComputerUseDriverError };
|
|
31
|
-
export {
|
|
32
|
-
type DnsLookupFn,
|
|
33
|
-
type SsrfPinningProxy,
|
|
34
|
-
type StartSsrfPinningProxyOptions,
|
|
35
|
-
startSsrfPinningProxy,
|
|
36
|
-
} from "./ssrf-proxy";
|
|
37
|
-
|
|
38
|
-
export type DriverBackend = "host" | "chromium" | "remote";
|
|
39
|
-
export type MouseButton = "left" | "right" | "middle";
|
|
40
|
-
|
|
41
|
-
export type Viewport = {
|
|
42
|
-
readonly width: number;
|
|
43
|
-
readonly height: number;
|
|
44
|
-
readonly devicePixelRatio: number;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export interface Driver {
|
|
48
|
-
readonly backend: DriverBackend;
|
|
49
|
-
/** Open / connect / launch chromium. Idempotent. */
|
|
50
|
-
connect(): Promise<void>;
|
|
51
|
-
/** Navigate the chromium tab. Throws on `host` backend. */
|
|
52
|
-
goto(url: string): Promise<void>;
|
|
53
|
-
/** Capture a PNG screenshot. */
|
|
54
|
-
screenshot(): Promise<Uint8Array>;
|
|
55
|
-
click(x: number, y: number, button?: MouseButton): Promise<void>;
|
|
56
|
-
type(text: string): Promise<void>;
|
|
57
|
-
key(combo: string): Promise<void>;
|
|
58
|
-
scroll(dx: number, dy: number): Promise<void>;
|
|
59
|
-
getViewport(): Promise<Viewport>;
|
|
60
|
-
/** Best-effort DOM snapshot. Used by tests + the post-action assertions. */
|
|
61
|
-
domText?(): Promise<string>;
|
|
62
|
-
disconnect(): Promise<void>;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export type CreateDriverOptions = {
|
|
66
|
-
readonly backend: DriverBackend;
|
|
67
|
-
/** Playwright launch overrides (e.g. headless: false). chromium-only. */
|
|
68
|
-
readonly playwrightOptions?: Record<string, unknown>;
|
|
69
|
-
/**
|
|
70
|
-
* SECURITY (audit follow-up R1) — route ALL chromium traffic through a
|
|
71
|
-
* local DNS-pinning forward proxy (see `ssrf-proxy.ts`). The proxy resolves
|
|
72
|
-
* each hostname once, validates the IP against the private/loopback/
|
|
73
|
-
* link-local/metadata floor, and dials that exact pinned IP, closing the
|
|
74
|
-
* DNS-rebinding TOCTOU the pre-goto guard in tool-navigate cannot close
|
|
75
|
-
* (the browser re-resolves at connect time, and sub-resource fetches never
|
|
76
|
-
* pass the guard at all). Default true. Set `false` ONLY when the browser
|
|
77
|
-
* must reach private/internal targets (e.g. testing an intranet app) AND
|
|
78
|
-
* the page content is trusted. Ignored when `playwrightOptions.proxy` is
|
|
79
|
-
* set — an operator-supplied proxy wins, and is then responsible for its
|
|
80
|
-
* own egress policy. chromium-only.
|
|
81
|
-
*/
|
|
82
|
-
readonly ssrfProxy?: boolean;
|
|
83
|
-
/** Initial viewport — chromium-only. Default 1280x720@1. */
|
|
84
|
-
readonly viewport?: { width: number; height: number };
|
|
85
|
-
/** Test injection: a pre-built Driver instance the factory returns verbatim. */
|
|
86
|
-
readonly _injected?: Driver;
|
|
87
|
-
/**
|
|
88
|
-
* Test injection: replaces the chromium backend's dynamic
|
|
89
|
-
* `import("playwright")`. Lets the import-failure path be exercised without
|
|
90
|
-
* `mock.module` — a throwing module mock cannot be registered once
|
|
91
|
-
* playwright has been imported anywhere in the test process. chromium-only.
|
|
92
|
-
*/
|
|
93
|
-
readonly _importPlaywright?: () => Promise<unknown>;
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
export function createDriver(opts: CreateDriverOptions): Driver {
|
|
97
|
-
if (opts._injected !== undefined) return opts._injected;
|
|
98
|
-
if (opts.backend === "chromium") return createChromiumDriver(opts);
|
|
99
|
-
if (opts.backend === "host") {
|
|
100
|
-
// Section 30 — host backend stays gated. Callers that have wired
|
|
101
|
-
// a real executor use `createHostDriver` directly.
|
|
102
|
-
return createHostDriverStub();
|
|
103
|
-
}
|
|
104
|
-
if (opts.backend === "remote") {
|
|
105
|
-
// Same pattern: callers with a puppeteer-core import use
|
|
106
|
-
// `createRemoteDriver` directly.
|
|
107
|
-
return createRemoteDriverStub();
|
|
108
|
-
}
|
|
109
|
-
throw new ComputerUseDriverError(`unknown backend: ${opts.backend}`);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Section 30 — direct exports for callers that wire backends with an
|
|
113
|
-
// executor or puppeteer-core import.
|
|
114
|
-
export { createHostDriver, type HostBackendOptions, type HostExecutor } from "./backends/host";
|
|
115
|
-
export {
|
|
116
|
-
createRemoteDriver,
|
|
117
|
-
type PuppeteerCoreLike,
|
|
118
|
-
type RemoteBackendOptions,
|
|
119
|
-
} from "./backends/remote";
|
|
120
|
-
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// Playwright-backed chromium driver.
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
|
|
125
|
-
function createChromiumDriver(opts: CreateDriverOptions): Driver {
|
|
126
|
-
let browser: unknown;
|
|
127
|
-
let context: unknown;
|
|
128
|
-
let page: unknown;
|
|
129
|
-
let connected = false;
|
|
130
|
-
let ssrfProxy: SsrfPinningProxy | undefined;
|
|
131
|
-
|
|
132
|
-
async function loadPlaywright(): Promise<{
|
|
133
|
-
chromium: { launch: (...args: unknown[]) => unknown };
|
|
134
|
-
}> {
|
|
135
|
-
try {
|
|
136
|
-
// Lazy import so non-browser users don't need Playwright in their tree.
|
|
137
|
-
// The import is dynamic via a string indirection so `tsc` doesn't fail
|
|
138
|
-
// when playwright (an optional peer dep) isn't installed in CI.
|
|
139
|
-
const playwrightModule = "playwright";
|
|
140
|
-
const mod =
|
|
141
|
-
opts._importPlaywright !== undefined
|
|
142
|
-
? await opts._importPlaywright()
|
|
143
|
-
: await import(playwrightModule);
|
|
144
|
-
return mod as unknown as { chromium: { launch: (...args: unknown[]) => unknown } };
|
|
145
|
-
} catch (err) {
|
|
146
|
-
throw new ComputerUseDriverError(
|
|
147
|
-
"Playwright not installed. Run `bun add -D playwright` and `bunx playwright install chromium` to use the chromium backend.",
|
|
148
|
-
err,
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
backend: "chromium",
|
|
155
|
-
async connect() {
|
|
156
|
-
if (connected) return;
|
|
157
|
-
const { chromium } = await loadPlaywright();
|
|
158
|
-
const userOpts = opts.playwrightOptions ?? {};
|
|
159
|
-
// SECURITY — DNS-pinning proxy on by default (see CreateDriverOptions.
|
|
160
|
-
// ssrfProxy). An operator-supplied playwrightOptions.proxy wins; the
|
|
161
|
-
// proxy starts only after the playwright import succeeds so the
|
|
162
|
-
// import-failure path can't leak a listening server.
|
|
163
|
-
const useSsrfProxy = opts.ssrfProxy !== false && userOpts["proxy"] === undefined;
|
|
164
|
-
if (useSsrfProxy) {
|
|
165
|
-
ssrfProxy = await startSsrfPinningProxy();
|
|
166
|
-
}
|
|
167
|
-
try {
|
|
168
|
-
// Playwright defaults: chromium runs in its own sandbox (its own
|
|
169
|
-
// sandboxed renderer process). Headless by default. The
|
|
170
|
-
// `<-loopback>` bypass rule removes Chromium's implicit proxy bypass
|
|
171
|
-
// for localhost targets, so loopback requests also route through the
|
|
172
|
-
// pinning proxy — and get blocked there.
|
|
173
|
-
const launchOpts: Record<string, unknown> = {
|
|
174
|
-
headless: true,
|
|
175
|
-
...userOpts,
|
|
176
|
-
...(ssrfProxy !== undefined
|
|
177
|
-
? { proxy: { server: ssrfProxy.url, bypass: "<-loopback>" } }
|
|
178
|
-
: {}),
|
|
179
|
-
};
|
|
180
|
-
browser = await (chromium.launch as (o: unknown) => Promise<unknown>)(launchOpts);
|
|
181
|
-
const ctxOpts: Record<string, unknown> = {
|
|
182
|
-
viewport: opts.viewport ?? { width: 1280, height: 720 },
|
|
183
|
-
};
|
|
184
|
-
context = await (browser as { newContext: (o: unknown) => Promise<unknown> }).newContext(
|
|
185
|
-
ctxOpts,
|
|
186
|
-
);
|
|
187
|
-
page = await (context as { newPage: () => Promise<unknown> }).newPage();
|
|
188
|
-
} catch (err) {
|
|
189
|
-
// Launch/context failure must not leak the proxy server (or a
|
|
190
|
-
// half-launched browser).
|
|
191
|
-
try {
|
|
192
|
-
await (browser as { close?: () => Promise<void> } | undefined)?.close?.();
|
|
193
|
-
} catch {
|
|
194
|
-
// best-effort
|
|
195
|
-
}
|
|
196
|
-
browser = undefined;
|
|
197
|
-
await ssrfProxy?.close();
|
|
198
|
-
ssrfProxy = undefined;
|
|
199
|
-
throw err;
|
|
200
|
-
}
|
|
201
|
-
connected = true;
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
async goto(url) {
|
|
205
|
-
if (!connected)
|
|
206
|
-
throw new ComputerUseDriverError("driver not connected — call connect() first");
|
|
207
|
-
await (page as { goto: (u: string, o?: unknown) => Promise<unknown> }).goto(url, {
|
|
208
|
-
waitUntil: "domcontentloaded",
|
|
209
|
-
});
|
|
210
|
-
},
|
|
211
|
-
|
|
212
|
-
async screenshot() {
|
|
213
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
214
|
-
const buf = (await (page as { screenshot: (o?: unknown) => Promise<unknown> }).screenshot({
|
|
215
|
-
type: "png",
|
|
216
|
-
})) as Uint8Array;
|
|
217
|
-
return buf;
|
|
218
|
-
},
|
|
219
|
-
|
|
220
|
-
async click(x, y, button = "left") {
|
|
221
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
222
|
-
await (
|
|
223
|
-
page as { mouse: { click: (x: number, y: number, o?: unknown) => Promise<void> } }
|
|
224
|
-
).mouse.click(x, y, { button });
|
|
225
|
-
},
|
|
226
|
-
|
|
227
|
-
async type(text) {
|
|
228
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
229
|
-
await (page as { keyboard: { type: (t: string) => Promise<void> } }).keyboard.type(text);
|
|
230
|
-
},
|
|
231
|
-
|
|
232
|
-
async key(combo) {
|
|
233
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
234
|
-
await (page as { keyboard: { press: (k: string) => Promise<void> } }).keyboard.press(combo);
|
|
235
|
-
},
|
|
236
|
-
|
|
237
|
-
async scroll(dx, dy) {
|
|
238
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
239
|
-
await (page as { mouse: { wheel: (dx: number, dy: number) => Promise<void> } }).mouse.wheel(
|
|
240
|
-
dx,
|
|
241
|
-
dy,
|
|
242
|
-
);
|
|
243
|
-
},
|
|
244
|
-
|
|
245
|
-
async getViewport() {
|
|
246
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
247
|
-
const vp = (
|
|
248
|
-
page as { viewportSize: () => { width: number; height: number } | null }
|
|
249
|
-
).viewportSize();
|
|
250
|
-
const w = vp?.width ?? opts.viewport?.width ?? 1280;
|
|
251
|
-
const h = vp?.height ?? opts.viewport?.height ?? 720;
|
|
252
|
-
return { width: w, height: h, devicePixelRatio: 1 };
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
async domText() {
|
|
256
|
-
if (!connected) throw new ComputerUseDriverError("driver not connected");
|
|
257
|
-
return (page as { textContent: (sel: string) => Promise<string | null> })
|
|
258
|
-
.textContent("body")
|
|
259
|
-
.then((t) => t ?? "");
|
|
260
|
-
},
|
|
261
|
-
|
|
262
|
-
async disconnect() {
|
|
263
|
-
if (!connected) return;
|
|
264
|
-
try {
|
|
265
|
-
await (browser as { close: () => Promise<void> }).close();
|
|
266
|
-
} catch {
|
|
267
|
-
// best-effort
|
|
268
|
-
}
|
|
269
|
-
await ssrfProxy?.close();
|
|
270
|
-
ssrfProxy = undefined;
|
|
271
|
-
connected = false;
|
|
272
|
-
browser = undefined;
|
|
273
|
-
context = undefined;
|
|
274
|
-
page = undefined;
|
|
275
|
-
},
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// ---------------------------------------------------------------------------
|
|
280
|
-
// Stubs for host + remote backends.
|
|
281
|
-
// ---------------------------------------------------------------------------
|
|
282
|
-
|
|
283
|
-
function createHostDriverStub(): Driver {
|
|
284
|
-
return {
|
|
285
|
-
backend: "host",
|
|
286
|
-
async connect() {
|
|
287
|
-
throw new ComputerUseDriverError(
|
|
288
|
-
"host backend is not implemented in v0. The kickoff explicitly forbids it for smokes (would drive the dev's actual desktop). Native macOS / Linux / Windows backends land in a follow-up gated on `CREWHAUS_BROW_HOST_SMOKE=1`.",
|
|
289
|
-
);
|
|
290
|
-
},
|
|
291
|
-
async goto() {
|
|
292
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
293
|
-
},
|
|
294
|
-
async screenshot() {
|
|
295
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
296
|
-
},
|
|
297
|
-
async click() {
|
|
298
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
299
|
-
},
|
|
300
|
-
async type() {
|
|
301
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
302
|
-
},
|
|
303
|
-
async key() {
|
|
304
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
305
|
-
},
|
|
306
|
-
async scroll() {
|
|
307
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
308
|
-
},
|
|
309
|
-
async getViewport() {
|
|
310
|
-
throw new ComputerUseDriverError("host backend not implemented");
|
|
311
|
-
},
|
|
312
|
-
async disconnect() {},
|
|
313
|
-
};
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function createRemoteDriverStub(): Driver {
|
|
317
|
-
return {
|
|
318
|
-
backend: "remote",
|
|
319
|
-
async connect() {
|
|
320
|
-
throw new ComputerUseDriverError(
|
|
321
|
-
"remote backend (CDP / Browserless) is not implemented in v0",
|
|
322
|
-
);
|
|
323
|
-
},
|
|
324
|
-
async goto() {
|
|
325
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
326
|
-
},
|
|
327
|
-
async screenshot() {
|
|
328
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
329
|
-
},
|
|
330
|
-
async click() {
|
|
331
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
332
|
-
},
|
|
333
|
-
async type() {
|
|
334
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
335
|
-
},
|
|
336
|
-
async key() {
|
|
337
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
338
|
-
},
|
|
339
|
-
async scroll() {
|
|
340
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
341
|
-
},
|
|
342
|
-
async getViewport() {
|
|
343
|
-
throw new ComputerUseDriverError("remote backend not implemented");
|
|
344
|
-
},
|
|
345
|
-
async disconnect() {},
|
|
346
|
-
};
|
|
347
|
-
}
|
package/src/ssrf-proxy.test.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
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
|
-
});
|