@crewhaus/tool-navigate 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-navigate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Navigate tool — drives driver.goto(url) for the BROW agent (Section 25 BROW)",
6
6
  "main": "src/index.ts",
@@ -12,17 +12,17 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/computer-use-driver": "0.1.1",
16
- "@crewhaus/errors": "0.1.1",
17
- "@crewhaus/tool-builder": "0.1.1",
18
- "@crewhaus/tool-catalog": "0.1.1",
15
+ "@crewhaus/computer-use-driver": "0.1.2",
16
+ "@crewhaus/errors": "0.1.2",
17
+ "@crewhaus/tool-builder": "0.1.2",
18
+ "@crewhaus/tool-catalog": "0.1.2",
19
19
  "zod": "^3.23.8"
20
20
  },
21
21
  "license": "Apache-2.0",
22
22
  "author": {
23
23
  "name": "Max Meier",
24
- "email": "max@studiomax.io",
25
- "url": "https://studiomax.io"
24
+ "email": "max@crewhaus.ai",
25
+ "url": "https://crewhaus.ai"
26
26
  },
27
27
  "repository": {
28
28
  "type": "git",
@@ -34,12 +34,7 @@
34
34
  "url": "https://github.com/crewhaus/factory/issues"
35
35
  },
36
36
  "publishConfig": {
37
- "access": "restricted"
37
+ "access": "public"
38
38
  },
39
- "files": [
40
- "src",
41
- "README.md",
42
- "LICENSE",
43
- "NOTICE"
44
- ]
39
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
45
40
  }
package/src/index.test.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
2
  import type { Driver } from "@crewhaus/computer-use-driver";
3
- import { NavigateError, createNavigateTool } from "./index.js";
3
+ import { NavigateError, _setDnsLookup, createNavigateTool } from "./index.js";
4
4
 
5
5
  type GotoRecord = { calls: string[] };
6
6
 
@@ -27,18 +27,22 @@ function stubDriver(record: GotoRecord, opts: { gotoErr?: Error } = {}): Driver
27
27
  }
28
28
 
29
29
  describe("createNavigateTool", () => {
30
+ // A public IP literal skips DNS resolution, keeping these tests hermetic
31
+ // while still passing the SSRF guard (it's a public, non-loopback address).
32
+ const PUBLIC_URL = "https://93.184.216.34/";
33
+
30
34
  test("happy path: forwards url to driver.goto and returns a text confirmation", async () => {
31
35
  const record: GotoRecord = { calls: [] };
32
36
  const tool = createNavigateTool({ driver: stubDriver(record) });
33
37
 
34
- const result = await tool.execute({ url: "https://example.com/" }, {});
35
- expect(record.calls).toEqual(["https://example.com/"]);
38
+ const result = await tool.execute({ url: PUBLIC_URL }, {});
39
+ expect(record.calls).toEqual([PUBLIC_URL]);
36
40
  expect(Array.isArray(result)).toBe(true);
37
41
  if (!Array.isArray(result)) throw new Error("expected ToolResultContent array");
38
42
  expect(result).toHaveLength(1);
39
43
  const block = result[0];
40
44
  if (block?.type !== "text") throw new Error("expected text block");
41
- expect(block.text).toBe("navigated to https://example.com/");
45
+ expect(block.text).toBe(`navigated to ${PUBLIC_URL}`);
42
46
  });
43
47
 
44
48
  test("schema rejects non-URL strings (and missing url)", () => {
@@ -51,15 +55,16 @@ describe("createNavigateTool", () => {
51
55
  test("driver.goto failure surfaces as NavigateError with the URL in the message", async () => {
52
56
  const record: GotoRecord = { calls: [] };
53
57
  const tool = createNavigateTool({
54
- driver: stubDriver(record, { gotoErr: new Error("net::ERR_NAME_NOT_RESOLVED") }),
58
+ driver: stubDriver(record, { gotoErr: new Error("net::ERR_CONNECTION_REFUSED") }),
55
59
  });
56
60
  try {
57
- await tool.execute({ url: "https://does-not-exist.invalid/" }, {});
61
+ // Public IP literal passes the SSRF guard and reaches driver.goto.
62
+ await tool.execute({ url: PUBLIC_URL }, {});
58
63
  throw new Error("expected NavigateError to be thrown");
59
64
  } catch (err) {
60
65
  expect(err).toBeInstanceOf(NavigateError);
61
- expect((err as Error).message).toContain("https://does-not-exist.invalid/");
62
- expect((err as Error).message).toContain("net::ERR_NAME_NOT_RESOLVED");
66
+ expect((err as Error).message).toContain(PUBLIC_URL);
67
+ expect((err as Error).message).toContain("net::ERR_CONNECTION_REFUSED");
63
68
  }
64
69
  });
65
70
 
@@ -76,3 +81,76 @@ describe("createNavigateTool", () => {
76
81
  expect(tool.name).toBe("Navigate");
77
82
  });
78
83
  });
84
+
85
+ // SECURITY: the model picks the navigation URL and is prompt-injectable, so
86
+ // an attacker who controls any untrusted input could steer it to file://,
87
+ // cloud metadata, or a loopback/private host and read the result back via
88
+ // Screenshot. The guard must run before driver.goto.
89
+ describe("Navigate SSRF guard", () => {
90
+ afterEach(() => _setDnsLookup(undefined));
91
+
92
+ const expectBlocked = async (url: string) => {
93
+ const record: GotoRecord = { calls: [] };
94
+ const tool = createNavigateTool({ driver: stubDriver(record) });
95
+ let threw: unknown;
96
+ try {
97
+ await tool.execute({ url }, {});
98
+ } catch (err) {
99
+ threw = err;
100
+ }
101
+ expect(threw).toBeInstanceOf(NavigateError);
102
+ // The browser must never have been touched.
103
+ expect(record.calls).toEqual([]);
104
+ };
105
+
106
+ test.each([
107
+ ["file scheme", "file:///etc/passwd"],
108
+ ["gopher scheme", "gopher://127.0.0.1:6379/"],
109
+ ["data scheme", "data:text/html,<h1>x</h1>"],
110
+ ["loopback IPv4", "http://127.0.0.1/"],
111
+ ["loopback name", "http://localhost:8080/admin"],
112
+ ["AWS/GCP metadata", "http://169.254.169.254/latest/meta-data/iam/security-credentials/"],
113
+ ["RFC1918 10.x", "http://10.0.0.5/"],
114
+ ["RFC1918 192.168", "http://192.168.1.1/"],
115
+ ["RFC1918 172.16", "http://172.16.0.1/"],
116
+ ["CGNAT 100.64", "http://100.64.0.1/"],
117
+ ["0.0.0.0", "http://0.0.0.0/"],
118
+ ["IPv6 loopback", "http://[::1]/"],
119
+ ["IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]/"],
120
+ ["IPv4-mapped IPv6 metadata", "http://[::ffff:169.254.169.254]/"],
121
+ ["octal-encoded loopback", "http://0177.0.0.1/"],
122
+ ["integer-encoded loopback", "http://2130706433/"],
123
+ ["mDNS .local", "http://printer.local/"],
124
+ ])("blocks %s", async (_label, url) => {
125
+ await expectBlocked(url);
126
+ });
127
+
128
+ test("blocks a public hostname that resolves to a private IP (DNS rebinding at check time)", async () => {
129
+ _setDnsLookup(async () => ({ address: "127.0.0.1", family: 4 }));
130
+ const record: GotoRecord = { calls: [] };
131
+ const tool = createNavigateTool({ driver: stubDriver(record) });
132
+ await expect(
133
+ tool.execute({ url: "http://totally-innocent.example/" }, {}),
134
+ ).rejects.toBeInstanceOf(NavigateError);
135
+ expect(record.calls).toEqual([]);
136
+ });
137
+
138
+ test("allows a public hostname that resolves to a public IP", async () => {
139
+ _setDnsLookup(async () => ({ address: "93.184.216.34", family: 4 }));
140
+ const record: GotoRecord = { calls: [] };
141
+ const tool = createNavigateTool({ driver: stubDriver(record) });
142
+ await tool.execute({ url: "http://example.com/" }, {});
143
+ expect(record.calls).toEqual(["http://example.com/"]);
144
+ });
145
+
146
+ test("allows a public IP literal without any DNS lookup", async () => {
147
+ // If DNS were consulted for an IP literal this would throw.
148
+ _setDnsLookup(async () => {
149
+ throw new Error("DNS must not be called for an IP literal");
150
+ });
151
+ const record: GotoRecord = { calls: [] };
152
+ const tool = createNavigateTool({ driver: stubDriver(record) });
153
+ await tool.execute({ url: "https://93.184.216.34/" }, {});
154
+ expect(record.calls).toEqual(["https://93.184.216.34/"]);
155
+ });
156
+ });
package/src/index.ts CHANGED
@@ -15,10 +15,16 @@
15
15
  * `tool-mouse-keyboard`.
16
16
  *
17
17
  * Scope is `"external"` because the URL request crosses a network
18
- * boundary; the §3 egress-classifier can hook in here later. Output
18
+ * boundary. Before handing the URL to the browser we run an SSRF guard
19
+ * (`assertSafeNavigationTarget`): the model picks this URL and the model
20
+ * is prompt-injectable, so an attacker who controls any untrusted input
21
+ * (a channel message, fetched page, MCP result) could otherwise steer it
22
+ * to `file:///etc/passwd`, `http://169.254.169.254/…` (cloud metadata) or
23
+ * a loopback admin port and read the result back via `Screenshot`. Output
19
24
  * classification is off — the response is a deterministic short string,
20
25
  * not model-generated content.
21
26
  */
27
+ import { lookup as nodeDnsLookup } from "node:dns/promises";
22
28
  import type { Driver } from "@crewhaus/computer-use-driver";
23
29
  import { CrewhausError } from "@crewhaus/errors";
24
30
  import { buildTool } from "@crewhaus/tool-builder";
@@ -39,6 +45,176 @@ const navigateSchema = z
39
45
  })
40
46
  .strict();
41
47
 
48
+ /** Only real web schemes — blocks file:, gopher:, chrome:, data:, about:, etc. */
49
+ const ALLOWED_SCHEMES = new Set(["http:", "https:"]);
50
+
51
+ export type DnsLookupFn = (
52
+ hostname: string,
53
+ ) => Promise<{ readonly address: string; readonly family: number }>;
54
+
55
+ let dnsLookupFn: DnsLookupFn = nodeDnsLookup;
56
+
57
+ /** Test hook: override DNS resolution. Pass `undefined` to restore the default. */
58
+ export function _setDnsLookup(fn: DnsLookupFn | undefined): void {
59
+ dnsLookupFn = fn ?? nodeDnsLookup;
60
+ }
61
+
62
+ /**
63
+ * SSRF defenses below mirror `@crewhaus/tool-fetch` (`isPrivateIp` /
64
+ * `normalizeIpv4`). They are duplicated rather than imported to avoid
65
+ * coupling the browser bundle to tool-fetch's module-level config state —
66
+ * keep the copies in sync. The IPv4-mapped IPv6 handling (`::ffff:…`, both
67
+ * dotted and hex-compressed) is load-bearing: `new URL` normalizes
68
+ * `http://[::ffff:127.0.0.1]/` to `[::ffff:7f00:1]`, which would otherwise
69
+ * slip past a naive loopback check.
70
+ */
71
+ function normalizeIpv4(raw: string): string | null {
72
+ const trimmed = raw.trim();
73
+ if (trimmed === "") return null;
74
+
75
+ const parseComponent = (s: string): number | null => {
76
+ if (s === "") return null;
77
+ let value: number;
78
+ if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
79
+ value = Number.parseInt(s.slice(2), 16);
80
+ } else if (/^0[0-7]+$/.test(s)) {
81
+ value = Number.parseInt(s, 8);
82
+ } else if (/^[0-9]+$/.test(s)) {
83
+ value = Number.parseInt(s, 10);
84
+ } else {
85
+ return null;
86
+ }
87
+ return Number.isNaN(value) ? null : value;
88
+ };
89
+
90
+ const segments = trimmed.split(".");
91
+ if (segments.length > 4) return null;
92
+
93
+ const components: number[] = [];
94
+ for (const seg of segments) {
95
+ const value = parseComponent(seg);
96
+ if (value === null || value < 0) return null;
97
+ components.push(value);
98
+ }
99
+
100
+ const n = components.length;
101
+ const octets = [0, 0, 0, 0];
102
+ for (let i = 0; i < n - 1; i++) {
103
+ const c = components[i] as number;
104
+ if (c > 255) return null;
105
+ octets[i] = c;
106
+ }
107
+ const last = components[n - 1] as number;
108
+ const maxLast = 2 ** (8 * (4 - (n - 1)));
109
+ if (last >= maxLast) return null;
110
+ let rest = last;
111
+ for (let i = 3; i >= n - 1; i--) {
112
+ octets[i] = rest & 0xff;
113
+ rest = Math.floor(rest / 256);
114
+ }
115
+ return octets.join(".");
116
+ }
117
+
118
+ function isPrivateIp(addr: string): boolean {
119
+ const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
120
+ const lowerIp = ip.toLowerCase();
121
+
122
+ // IPv6 unspecified / loopback / link-local / unique-local
123
+ if (lowerIp === "::" || lowerIp === "::1") return true;
124
+ if (lowerIp.startsWith("fe80:")) return true;
125
+ if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd")) return true;
126
+
127
+ // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by
128
+ // the embedded IPv4 address so a mapped literal can't smuggle past.
129
+ const mapped = lowerIp.match(/^::ffff:(.+)$/);
130
+ if (mapped) {
131
+ const tail = mapped[1] as string;
132
+ if (tail.includes(".")) {
133
+ return isPrivateIp(tail);
134
+ }
135
+ const hexGroups = tail.split(":");
136
+ if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
137
+ const hi = Number.parseInt(hexGroups[0] as string, 16);
138
+ const lo = Number.parseInt(hexGroups[1] as string, 16);
139
+ return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
140
+ }
141
+ return false;
142
+ }
143
+
144
+ // IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
145
+ const normalized = normalizeIpv4(ip);
146
+ if (normalized === null) return false;
147
+ const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
148
+ if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
149
+ return false;
150
+ }
151
+ const [a, b] = parts as [number, number, number, number];
152
+
153
+ if (a === 127) return true; // 127.0.0.0/8 loopback
154
+ if (a === 10) return true; // 10.0.0.0/8 RFC1918
155
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata
156
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
157
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
158
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (RFC6598)
159
+ if (a === 192 && b === 0 && parts[2] === 0) return true; // 192.0.0.0/24 (RFC6890)
160
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 (RFC2544)
161
+ if (a === 0) return true; // 0.0.0.0/8
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Reject anything that isn't a public http/https target before the browser
167
+ * sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
168
+ * literals, loopback/mDNS names, and names that resolve to a private range
169
+ * at check time. The rebinding TOCTOU this check alone cannot close (the
170
+ * browser re-resolves DNS at connect time, and sub-resource fetches never
171
+ * pass through here) is closed at the connection layer by the chromium
172
+ * backend's DNS-pinning proxy — see `ssrf-proxy.ts` in computer-use-driver,
173
+ * on by default. The remote (CDP/Browserless) backend launches its browser
174
+ * elsewhere, so for that backend this pre-goto guard is still the only
175
+ * navigate-side control; pin egress at the remote browser's host.
176
+ */
177
+ export async function assertSafeNavigationTarget(rawUrl: string): Promise<void> {
178
+ let url: URL;
179
+ try {
180
+ url = new URL(rawUrl);
181
+ } catch {
182
+ throw new NavigateError(`navigation to ${rawUrl} blocked: not a valid absolute URL`);
183
+ }
184
+ if (!ALLOWED_SCHEMES.has(url.protocol)) {
185
+ throw new NavigateError(
186
+ `navigation to ${rawUrl} blocked: only http/https is allowed (got "${url.protocol}")`,
187
+ );
188
+ }
189
+ const hostname = url.hostname.toLowerCase();
190
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
191
+ throw new NavigateError(`navigation to ${rawUrl} blocked: loopback host`);
192
+ }
193
+ if (hostname.endsWith(".local")) {
194
+ throw new NavigateError(`navigation to ${rawUrl} blocked: mDNS host "${hostname}"`);
195
+ }
196
+ const unbracketed = hostname.replace(/^\[/, "").replace(/\]$/, "");
197
+ if (isPrivateIp(unbracketed)) {
198
+ throw new NavigateError(`navigation to ${rawUrl} blocked: private/loopback IP`);
199
+ }
200
+ // IP literals are their own target — nothing to resolve/rebind.
201
+ const isIpLiteral = normalizeIpv4(unbracketed) !== null || unbracketed.includes(":");
202
+ if (isIpLiteral) return;
203
+ let resolved: { readonly address: string };
204
+ try {
205
+ resolved = await dnsLookupFn(unbracketed);
206
+ } catch (err) {
207
+ throw new NavigateError(
208
+ `navigation to ${rawUrl} blocked: cannot resolve host: ${(err as Error).message}`,
209
+ );
210
+ }
211
+ if (isPrivateIp(resolved.address)) {
212
+ throw new NavigateError(
213
+ `navigation to ${rawUrl} blocked: host resolves to private IP ${resolved.address}`,
214
+ );
215
+ }
216
+ }
217
+
42
218
  export type CreateNavigateToolOptions = {
43
219
  readonly driver: Driver;
44
220
  };
@@ -55,6 +231,9 @@ export function createNavigateTool(opts: CreateNavigateToolOptions): RegisteredT
55
231
  classifyOutput: false,
56
232
  scope: "external",
57
233
  execute: async (input): Promise<ToolResultContent> => {
234
+ // SSRF guard runs BEFORE the browser touches the URL. Throws
235
+ // NavigateError directly so the block reason surfaces unwrapped.
236
+ await assertSafeNavigationTarget(input.url);
58
237
  try {
59
238
  await opts.driver.goto(input.url);
60
239
  } catch (err) {