@crewhaus/tool-navigate 0.5.8 → 0.7.0

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/index.d.ts CHANGED
@@ -11,6 +11,30 @@ export type DnsLookupFn = (hostname: string) => Promise<{
11
11
  }>;
12
12
  /** Test hook: override DNS resolution. Pass `undefined` to restore the default. */
13
13
  export declare function _setDnsLookup(fn: DnsLookupFn | undefined): void;
14
+ /**
15
+ * Canonicalise an IPv4 literal.
16
+ *
17
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
18
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
19
+ * only understands dotted-decimal waves every one of them through. Returns
20
+ * `null` when the string is not an IPv4 literal at all.
21
+ */
22
+ export declare function normalizeIpv4(raw: string): string | null;
23
+ /**
24
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
25
+ * is not one.
26
+ *
27
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
28
+ * has many spellings and the one a check was written against is rarely the one
29
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
30
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
31
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
32
+ * first means the prefix tests below are arithmetic, and spelling stops
33
+ * mattering.
34
+ */
35
+ export declare function parseIpv6(raw: string): ReadonlyArray<number> | null;
36
+ /** Ranges that are never a public RPC endpoint. */
37
+ export declare function isPrivateIp(address: string): boolean;
14
38
  /**
15
39
  * Reject anything that isn't a public http/https target before the browser
16
40
  * sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
package/dist/index.js CHANGED
@@ -47,121 +47,206 @@ let dnsLookupFn = nodeDnsLookup;
47
47
  export function _setDnsLookup(fn) {
48
48
  dnsLookupFn = fn ?? nodeDnsLookup;
49
49
  }
50
+ // ---------------------------------------------------------------------------
51
+ // BEGIN SYNCHRONISED BLOCK — private-address classifier
52
+ //
53
+ // This block is BYTE-IDENTICAL across every package that guards an outbound
54
+ // request. Do not edit one copy: `apps/cli/src/tool-registry.test.ts` hashes
55
+ // them all and fails if any differs, and it asserts how many it found, because
56
+ // a copy-scanning guard that matches nothing reports green.
57
+ //
58
+ // It exists in copies rather than a shared package because these files are
59
+ // otherwise independent per-package networking layers; a guard proving the
60
+ // copies are identical is cheaper and safer than the import graph a shared
61
+ // package would need across `crawler`, `computer-use-driver` and ten tools.
62
+ //
63
+ // WHY IT PARSES INSTEAD OF MATCHING TEXT. Six copies were confirmed
64
+ // exploitable on 2026-09-18 because they compared address STRINGS. The WHATWG
65
+ // URL parser rewrites `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, so a
66
+ // text check never sees the spelling it was written for; and `64:ff9b::a9fe:a9fe`
67
+ // IS 169.254.169.254 on any network running DNS64/NAT64. Parsing numerically and
68
+ // recursing into the embedded IPv4 is the only form that holds.
69
+ //
70
+ // WHAT IT CANNOT DO. RFC 6052 lets an operator choose any Network-Specific
71
+ // Prefix for NAT64, so an embedded IPv4 behind an arbitrary NSP is undecidable
72
+ // from the address alone. That case is configuration, and the callers that need
73
+ // it resolve the host and re-check the ANSWER before dialling.
74
+ // ---------------------------------------------------------------------------
50
75
  /**
51
- * SSRF defenses below mirror `@crewhaus/tool-fetch` (`isPrivateIp` /
52
- * `normalizeIpv4`). They are duplicated rather than imported to avoid
53
- * coupling the browser bundle to tool-fetch's module-level config state —
54
- * keep the copies in sync. The IPv4-mapped IPv6 handling (`::ffff:…`, both
55
- * dotted and hex-compressed) is load-bearing: `new URL` normalizes
56
- * `http://[::ffff:127.0.0.1]/` to `[::ffff:7f00:1]`, which would otherwise
57
- * slip past a naive loopback check.
76
+ * Canonicalise an IPv4 literal.
77
+ *
78
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
79
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
80
+ * only understands dotted-decimal waves every one of them through. Returns
81
+ * `null` when the string is not an IPv4 literal at all.
58
82
  */
59
- function normalizeIpv4(raw) {
83
+ export function normalizeIpv4(raw) {
60
84
  const trimmed = raw.trim();
61
- if (trimmed === "")
85
+ if (trimmed === "" || /[^0-9a-fA-FxX.]/.test(trimmed))
86
+ return null;
87
+ const parts = trimmed.split(".");
88
+ if (parts.length === 0 || parts.length > 4)
62
89
  return null;
63
- const parseComponent = (s) => {
64
- if (s === "")
90
+ const values = [];
91
+ for (const part of parts) {
92
+ if (part === "")
65
93
  return null;
66
94
  let value;
67
- if (/^0[xX][0-9a-fA-F]+$/.test(s)) {
68
- value = Number.parseInt(s.slice(2), 16);
69
- }
70
- else if (/^0[0-7]+$/.test(s)) {
71
- value = Number.parseInt(s, 8);
72
- }
73
- else if (/^[0-9]+$/.test(s)) {
74
- value = Number.parseInt(s, 10);
75
- }
76
- else {
95
+ if (/^0[xX][0-9a-fA-F]+$/.test(part))
96
+ value = Number.parseInt(part.slice(2), 16);
97
+ else if (/^0[0-7]+$/.test(part))
98
+ value = Number.parseInt(part.slice(1), 8);
99
+ else if (/^\d+$/.test(part))
100
+ value = Number.parseInt(part, 10);
101
+ else
77
102
  return null;
78
- }
79
- return Number.isNaN(value) ? null : value;
80
- };
81
- const segments = trimmed.split(".");
82
- if (segments.length > 4)
83
- return null;
84
- const components = [];
85
- for (const seg of segments) {
86
- const value = parseComponent(seg);
87
- if (value === null || value < 0)
103
+ if (!Number.isFinite(value) || value < 0)
88
104
  return null;
89
- components.push(value);
105
+ values.push(value);
90
106
  }
91
- const n = components.length;
92
- const octets = [0, 0, 0, 0];
93
- for (let i = 0; i < n - 1; i++) {
94
- const c = components[i];
95
- if (c > 255)
107
+ // The short forms pack the remaining octets into the last part: `127.1` is
108
+ // 127.0.0.1, not 127.1.0.0. Getting this backwards is how a bypass survives.
109
+ const last = values[values.length - 1];
110
+ const leading = values.slice(0, -1);
111
+ if (leading.some((v) => v > 255))
112
+ return null;
113
+ const remaining = 4 - leading.length;
114
+ if (last >= 2 ** (8 * remaining))
115
+ return null;
116
+ const octets = [...leading];
117
+ for (let i = remaining - 1; i >= 0; i--)
118
+ octets.push((last >>> (8 * i)) & 0xff);
119
+ return octets.join(".");
120
+ }
121
+ /**
122
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
123
+ * is not one.
124
+ *
125
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
126
+ * has many spellings and the one a check was written against is rarely the one
127
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
128
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
129
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
130
+ * first means the prefix tests below are arithmetic, and spelling stops
131
+ * mattering.
132
+ */
133
+ export function parseIpv6(raw) {
134
+ let text = raw.trim().toLowerCase();
135
+ if (text.startsWith("["))
136
+ text = text.slice(1);
137
+ if (text.endsWith("]"))
138
+ text = text.slice(0, -1);
139
+ const zone = text.indexOf("%"); // fe80::1%eth0
140
+ if (zone !== -1)
141
+ text = text.slice(0, zone);
142
+ if (!text.includes(":"))
143
+ return null;
144
+ // A trailing dotted quad is the last two groups written in IPv4. Require all
145
+ // three dots: without that, `::1` parses as a one-part inet_aton address and
146
+ // takes a path that has nothing to do with what was written.
147
+ const dotted = /^(.*:)(\d+(?:\.\d+){3})$/.exec(text);
148
+ if (dotted !== null) {
149
+ const quad = normalizeIpv4(dotted[2]);
150
+ if (quad === null)
96
151
  return null;
97
- octets[i] = c;
152
+ const o = quad.split(".").map((n) => Number.parseInt(n, 10));
153
+ const hi = (o[0] << 8) | o[1];
154
+ const lo = (o[2] << 8) | o[3];
155
+ text = `${dotted[1]}${hi.toString(16)}:${lo.toString(16)}`;
98
156
  }
99
- const last = components[n - 1];
100
- const maxLast = 2 ** (8 * (4 - (n - 1)));
101
- if (last >= maxLast)
157
+ const halves = text.split("::");
158
+ if (halves.length > 2)
159
+ return null;
160
+ const pieces = (part) => (part === "" ? [] : part.split(":"));
161
+ const head = pieces(halves[0]);
162
+ const tail = halves.length === 2 ? pieces(halves[1]) : [];
163
+ if (halves.length === 1 && head.length !== 8)
102
164
  return null;
103
- let rest = last;
104
- for (let i = 3; i >= n - 1; i--) {
105
- octets[i] = rest & 0xff;
106
- rest = Math.floor(rest / 256);
165
+ if (head.length + tail.length > 8)
166
+ return null;
167
+ const groups = [];
168
+ for (const piece of head) {
169
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
170
+ return null;
171
+ groups.push(Number.parseInt(piece, 16));
107
172
  }
108
- return octets.join(".");
173
+ for (let i = head.length + tail.length; i < 8; i++)
174
+ groups.push(0);
175
+ for (const piece of tail) {
176
+ if (!/^[0-9a-f]{1,4}$/.test(piece))
177
+ return null;
178
+ groups.push(Number.parseInt(piece, 16));
179
+ }
180
+ return groups.length === 8 ? groups : null;
109
181
  }
110
- function isPrivateIp(addr) {
111
- const ip = addr.replace(/^\[/, "").replace(/\]$/, "");
112
- const lowerIp = ip.toLowerCase();
113
- // IPv6 unspecified / loopback / link-local / unique-local
114
- if (lowerIp === "::" || lowerIp === "::1")
115
- return true;
116
- if (lowerIp.startsWith("fe80:"))
117
- return true;
118
- if (lowerIp.startsWith("fc") || lowerIp.startsWith("fd"))
119
- return true;
120
- // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1 / ::ffff:7f00:1) — classify by
121
- // the embedded IPv4 address so a mapped literal can't smuggle past.
122
- const mapped = lowerIp.match(/^::ffff:(.+)$/);
123
- if (mapped) {
124
- const tail = mapped[1];
125
- if (tail.includes(".")) {
126
- return isPrivateIp(tail);
127
- }
128
- const hexGroups = tail.split(":");
129
- if (hexGroups.length === 2 && hexGroups.every((g) => /^[0-9a-f]{1,4}$/.test(g))) {
130
- const hi = Number.parseInt(hexGroups[0], 16);
131
- const lo = Number.parseInt(hexGroups[1], 16);
132
- return isPrivateIp([(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join("."));
133
- }
134
- return false;
182
+ /**
183
+ * The IPv4 address an IPv6 address carries, when it carries one.
184
+ *
185
+ * Every transition mechanism embeds a v4 address somewhere, and every one of
186
+ * them is a way to reach a v4 destination while wearing a v6 spelling that no
187
+ * v4 range check looks at. The NAT64 well-known prefix is the sharpest: a
188
+ * DNS64 resolver answers an IPv4-only name with `64:ff9b::<the v4>`, so
189
+ * `64:ff9b::a9fe:a9fe` IS the metadata service on any network that runs one.
190
+ */
191
+ function embeddedIpv4(g) {
192
+ const quad = (hi, lo) => `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
193
+ const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
194
+ const last = quad(g[6], g[7]);
195
+ if (zeros(0, 5) && g[5] === 0xffff)
196
+ return last; // ::ffff:0:0/96, IPv4-mapped
197
+ if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0)
198
+ return last; // ::ffff:0:0:0/96, translated
199
+ if (g[0] === 0x64 && g[1] === 0xff9b)
200
+ return last; // 64:ff9b::/96 and 64:ff9b:1::/48, NAT64
201
+ if (g[0] === 0x2002)
202
+ return quad(g[1], g[2]); // 2002::/16, 6to4
203
+ // ::a.b.c.d, IPv4-compatible: deprecated, still routed by some stacks, and
204
+ // not to be confused with `::` or `::1`, which are handled before this.
205
+ if (zeros(0, 6))
206
+ return last;
207
+ return null;
208
+ }
209
+ /** Ranges that are never a public RPC endpoint. */
210
+ export function isPrivateIp(address) {
211
+ const v4 = normalizeIpv4(address);
212
+ if (v4 !== null) {
213
+ const [a = 0, b = 0, c = 0, d = 0] = v4.split(".").map((n) => Number.parseInt(n, 10));
214
+ if (a === 0 || a === 10 || a === 127)
215
+ return true;
216
+ if (a === 169 && b === 254)
217
+ return true; // link-local, and the metadata service
218
+ if (a === 172 && b >= 16 && b <= 31)
219
+ return true;
220
+ if (a === 192 && b === 168)
221
+ return true;
222
+ if (a === 192 && b === 0 && c === 0)
223
+ return true;
224
+ if (a === 100 && b >= 64 && b <= 127)
225
+ return true; // carrier-grade NAT
226
+ if (a === 198 && (b === 18 || b === 19))
227
+ return true; // benchmarking
228
+ if (a >= 224)
229
+ return true; // multicast, reserved, broadcast
230
+ return a === 255 && b === 255 && c === 255 && d === 255;
135
231
  }
136
- // IPv4 — normalise octal/hex/integer literals to dotted-decimal first.
137
- const normalized = normalizeIpv4(ip);
138
- if (normalized === null)
139
- return false;
140
- const parts = normalized.split(".").map((p) => Number.parseInt(p, 10));
141
- if (parts.length !== 4 || parts.some((nn) => Number.isNaN(nn) || nn < 0 || nn > 255)) {
232
+ const groups = parseIpv6(address);
233
+ if (groups === null)
142
234
  return false;
143
- }
144
- const [a, b] = parts;
145
- if (a === 127)
146
- return true; // 127.0.0.0/8 loopback
147
- if (a === 10)
148
- return true; // 10.0.0.0/8 RFC1918
149
- if (a === 169 && b === 254)
150
- return true; // 169.254.0.0/16 link-local + AWS metadata
151
- if (a === 172 && b >= 16 && b <= 31)
152
- return true; // 172.16.0.0/12 RFC1918
153
- if (a === 192 && b === 168)
154
- return true; // 192.168.0.0/16 RFC1918
155
- if (a === 100 && b >= 64 && b <= 127)
156
- return true; // 100.64.0.0/10 CGNAT (RFC6598)
157
- if (a === 192 && b === 0 && parts[2] === 0)
158
- return true; // 192.0.0.0/24 (RFC6890)
159
- if (a === 198 && (b === 18 || b === 19))
160
- return true; // 198.18.0.0/15 (RFC2544)
161
- if (a === 0)
162
- return true; // 0.0.0.0/8
163
- return false;
235
+ if (groups.every((g) => g === 0))
236
+ return true; // ::, the unspecified address
237
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
238
+ return true; // ::1
239
+ const carried = embeddedIpv4(groups);
240
+ if (carried !== null)
241
+ return isPrivateIp(carried);
242
+ const head = groups[0];
243
+ if ((head & 0xfe00) === 0xfc00)
244
+ return true; // fc00::/7, unique-local
245
+ if ((head & 0xffc0) === 0xfe80)
246
+ return true; // fe80::/10, link-local
247
+ return (head & 0xff00) === 0xff00; // ff00::/8, multicast
164
248
  }
249
+ // END SYNCHRONISED BLOCK
165
250
  /**
166
251
  * Reject anything that isn't a public http/https target before the browser
167
252
  * sees it. This guard blocks the direct vectors — non-http(s) schemes, IP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-navigate",
3
- "version": "0.5.8",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "description": "Navigate tool — drives driver.goto(url) for the BROW agent (Section 25 BROW)",
6
6
  "main": "dist/index.js",
@@ -15,10 +15,10 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/computer-use-driver": "0.5.8",
19
- "@crewhaus/errors": "0.5.8",
20
- "@crewhaus/tool-builder": "0.5.8",
21
- "@crewhaus/tool-catalog": "0.5.8",
18
+ "@crewhaus/computer-use-driver": "0.7.0",
19
+ "@crewhaus/errors": "0.7.0",
20
+ "@crewhaus/tool-builder": "0.7.0",
21
+ "@crewhaus/tool-catalog": "0.7.0",
22
22
  "zod": "^3.23.8"
23
23
  },
24
24
  "license": "Apache-2.0",