@crewhaus/tool-navigate 0.6.0 → 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 +24 -0
- package/dist/index.js +183 -98
- package/package.json +5 -5
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* `
|
|
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
|
|
64
|
-
|
|
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(
|
|
68
|
-
value = Number.parseInt(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else
|
|
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
|
-
|
|
105
|
+
values.push(value);
|
|
90
106
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
if (
|
|
146
|
-
return true; //
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
return true; //
|
|
153
|
-
if (
|
|
154
|
-
return true; //
|
|
155
|
-
|
|
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.
|
|
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.
|
|
19
|
-
"@crewhaus/errors": "0.
|
|
20
|
-
"@crewhaus/tool-builder": "0.
|
|
21
|
-
"@crewhaus/tool-catalog": "0.
|
|
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",
|