@cruxy/cli 0.26.0 → 0.27.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/web/ssrf.js CHANGED
@@ -1,5 +1,5 @@
1
- import { lookup } from "node:dns";
2
1
  import { Agent } from "undici";
2
+ import { assertAllPublic, BlockedHostError, pinnedLookup, } from "../net/ip-guard.js";
3
3
  /**
4
4
  * SSRF guard (C.20). A URL the MODEL chose must not be able to reach the user's
5
5
  * internal network, cloud metadata service, or loopback interface. Three layers:
@@ -17,170 +17,14 @@ import { Agent } from "undici";
17
17
  *
18
18
  * The check runs BEFORE any request is dispatched, and again on every redirect hop
19
19
  * (see fetch.ts). A block is a security refusal, distinct from a network failure.
20
+ *
21
+ * The IP range math, the pin shim, and the error types now live in the shared
22
+ * {@link ../net/ip-guard ip-guard} module (JC-A) — the ONE owner of "is this IP
23
+ * allowed"; this file keeps only the web-specific policy (scheme + the
24
+ * `allowPrivateHosts` escape hatch) and the web one-shot dispatcher.
20
25
  */
21
- /** Default resolver: node's `dns.lookup` returning ALL addresses. */
22
- export const defaultResolveHost = (host) => new Promise((resolve, reject) => {
23
- lookup(host, { all: true }, (err, addresses) => {
24
- if (err)
25
- reject(err);
26
- else
27
- resolve(addresses.map((a) => a.address));
28
- });
29
- });
30
- /** Thrown when a URL is refused pre-dispatch; carries a human reason. */
31
- export class BlockedHostError extends Error {
32
- }
33
- /** Thrown when the host could not be resolved (a network failure, not a block). */
34
- export class HostUnresolvedError extends Error {
35
- }
36
- /** Parse a dotted-quad IPv4 string into its four octets, or null. */
37
- function parseIpv4(ip) {
38
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
39
- if (!m)
40
- return null;
41
- const octets = m.slice(1, 5).map((s) => Number(s));
42
- if (octets.some((o) => o > 255))
43
- return null;
44
- return octets;
45
- }
46
- /** True if an IPv4 address falls in a private/loopback/link-local/reserved range. */
47
- function isBlockedIpv4(ip) {
48
- const octets = parseIpv4(ip);
49
- if (!octets)
50
- return false;
51
- const [a, b] = octets;
52
- if (a === 0)
53
- return true; // 0.0.0.0/8 "this network" / unspecified
54
- if (a === 10)
55
- return true; // 10.0.0.0/8 private
56
- if (a === 127)
57
- return true; // 127.0.0.0/8 loopback
58
- if (a === 169 && b === 254)
59
- return true; // 169.254.0.0/16 link-local (incl. 169.254.169.254 metadata)
60
- if (a === 172 && b >= 16 && b <= 31)
61
- return true; // 172.16.0.0/12 private
62
- if (a === 192 && b === 168)
63
- return true; // 192.168.0.0/16 private
64
- if (a === 100 && b >= 64 && b <= 127)
65
- return true; // 100.64.0.0/10 CGNAT
66
- if (a === 198 && (b === 18 || b === 19))
67
- return true; // 198.18.0.0/15 benchmarking
68
- if (a === 255 && b === 255)
69
- return true; // broadcast-ish
70
- return false;
71
- }
72
- /**
73
- * Expand an IPv6 literal into its 8 sixteen-bit groups, or null if unparseable.
74
- * Handles `::` compression and an embedded IPv4 tail (`::ffff:127.0.0.1`).
75
- */
76
- function parseIpv6(input) {
77
- let s = input;
78
- const tail = [];
79
- // Peel off a trailing dotted-quad (IPv4-mapped/-compatible forms).
80
- const v4 = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(s);
81
- if (v4) {
82
- const o = parseIpv4(v4[1]);
83
- if (!o)
84
- return null;
85
- tail.push((o[0] << 8) | o[1], (o[2] << 8) | o[3]);
86
- s = s.slice(0, v4.index); // leaves a trailing ':' before the compression split
87
- }
88
- const halves = s.split("::");
89
- if (halves.length > 2)
90
- return null; // more than one "::" is illegal
91
- const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
92
- const rest = halves[1] ? halves[1].split(":").filter(Boolean) : [];
93
- const toNums = (groups) => {
94
- const out = [];
95
- for (const g of groups) {
96
- if (!/^[0-9a-f]{1,4}$/.test(g))
97
- return null;
98
- out.push(parseInt(g, 16));
99
- }
100
- return out;
101
- };
102
- const headNums = toNums(head);
103
- const restNums = toNums(rest);
104
- if (!headNums || !restNums)
105
- return null;
106
- let groups;
107
- if (halves.length === 2) {
108
- const fill = 8 - (headNums.length + restNums.length + tail.length);
109
- if (fill < 0)
110
- return null;
111
- groups = [
112
- ...headNums,
113
- ...Array(fill).fill(0),
114
- ...restNums,
115
- ...tail,
116
- ];
117
- }
118
- else {
119
- groups = [...headNums, ...tail];
120
- }
121
- return groups.length === 8 ? groups : null;
122
- }
123
- /** True if an expanded IPv6 address is in a range `web_fetch` must never reach. */
124
- function isBlockedIpv6(g) {
125
- // IPv4-mapped (::ffff:a.b.c.d) and IPv4-compatible (::a.b.c.d): check the v4 part.
126
- const firstFiveZero = g.slice(0, 5).every((x) => x === 0);
127
- const firstSixZero = firstFiveZero && g[5] === 0;
128
- const embedded = `${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`;
129
- if (firstFiveZero && g[5] === 0xffff)
130
- return isBlockedIpv4(embedded); // ::ffff:x
131
- if (firstSixZero &&
132
- !(g[6] === 0 && g[7] === 0) &&
133
- !(g[6] === 0 && g[7] === 1))
134
- return isBlockedIpv4(embedded); // ::x.y.z.w (IPv4-compatible, deprecated)
135
- if (g.every((x) => x === 0))
136
- return true; // :: unspecified
137
- if (firstSixZero && g[6] === 0 && g[7] === 1)
138
- return true; // ::1 loopback
139
- if ((g[0] & 0xffc0) === 0xfe80)
140
- return true; // fe80::/10 link-local (fe80–febf)
141
- if ((g[0] & 0xfe00) === 0xfc00)
142
- return true; // fc00::/7 unique-local (fc00–fdff)
143
- if ((g[0] & 0xff00) === 0xff00)
144
- return true; // ff00::/8 multicast
145
- return false;
146
- }
147
- /** True if an address (v4 or v6) is in a range `web_fetch` must never reach. */
148
- export function isBlockedAddress(addr) {
149
- // Strip IPv6 brackets and a scope/zone id (e.g. fe80::1%eth0).
150
- const ip = addr
151
- .trim()
152
- .toLowerCase()
153
- .replace(/^\[|\]$/g, "")
154
- .split("%")[0];
155
- if (ip.includes(":")) {
156
- const groups = parseIpv6(ip);
157
- if (!groups)
158
- return true; // fail closed: an unparseable colon-address is refused
159
- return isBlockedIpv6(groups);
160
- }
161
- return isBlockedIpv4(ip);
162
- }
163
- /**
164
- * A `dns.lookup`-compatible function that ignores the hostname and always hands
165
- * back one of the pre-validated `addresses`. This is what pins a connection to the
166
- * address the SSRF check already approved, defeating DNS rebinding: the socket can
167
- * only reach a validated IP, never a value re-resolved at connect time.
168
- */
169
- export function pinnedLookup(addresses) {
170
- const resolved = addresses.map((address) => ({
171
- address,
172
- family: address.includes(":") ? 6 : 4,
173
- }));
174
- return (_hostname, options, callback) => {
175
- const all = typeof options === "object" && options !== null && "all" in options
176
- ? options.all
177
- : false;
178
- if (all)
179
- callback(null, resolved);
180
- else
181
- callback(null, resolved[0].address, resolved[0].family);
182
- };
183
- }
26
+ // Re-export the shared surface so existing web importers/tests are unchanged.
27
+ export { BlockedHostError, HostUnresolvedError, isBlockedAddress, defaultResolveHost, pinnedLookup, } from "../net/ip-guard.js";
184
28
  /** An undici dispatcher whose connections are pinned to `addresses`. */
185
29
  export function createPinnedDispatcher(addresses) {
186
30
  return new Agent({ connect: { lookup: pinnedLookup(addresses) } });
@@ -188,8 +32,8 @@ export function createPinnedDispatcher(addresses) {
188
32
  /**
189
33
  * Assert that `url` may be fetched and return the validated addresses to pin the
190
34
  * connection to. Throws {@link BlockedHostError} for a bad scheme or a host
191
- * resolving into a blocked range, or {@link HostUnresolvedError} if the host cannot
192
- * be resolved.
35
+ * resolving into a blocked range, or `HostUnresolvedError` if the host cannot be
36
+ * resolved.
193
37
  *
194
38
  * The returned list is the exact set of addresses the caller must restrict the
195
39
  * connection to (via {@link createPinnedDispatcher}). An empty list means "do not
@@ -203,21 +47,5 @@ export async function assertFetchable(url, resolveHost, allowPrivate) {
203
47
  }
204
48
  if (allowPrivate)
205
49
  return [];
206
- const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
207
- let addresses;
208
- try {
209
- addresses = await resolveHost(host);
210
- }
211
- catch (err) {
212
- throw new HostUnresolvedError(`could not resolve host "${host}": ${err.message}`);
213
- }
214
- if (addresses.length === 0) {
215
- throw new HostUnresolvedError(`host "${host}" resolved to no addresses`);
216
- }
217
- for (const addr of addresses) {
218
- if (isBlockedAddress(addr)) {
219
- throw new BlockedHostError(`host "${host}" resolves to ${addr}, a private/loopback/link-local address`);
220
- }
221
- }
222
- return addresses;
50
+ return assertAllPublic(url.hostname, resolveHost);
223
51
  }
@@ -1,3 +1,4 @@
1
+ import type { HostResolver } from "../net/ip-guard.js";
1
2
  import type { WebConfig } from "../config/index.js";
2
3
  /**
3
4
  * Web subtool seams + shapes (C.20). Everything the `web_search`/`web_fetch`
@@ -44,9 +45,10 @@ export interface SearchProvider {
44
45
  /**
45
46
  * Resolve a hostname to its IP addresses. Injected so the SSRF guard can be tested
46
47
  * deterministically (a hostname that "resolves" to an internal IP) without real
47
- * DNS. Defaults to node's `dns.lookup` with `all: true`.
48
+ * DNS. Owned by the shared {@link ../net/ip-guard ip-guard} module (JC-A) and
49
+ * re-exported here for web importers.
48
50
  */
49
- export type HostResolver = (host: string) => Promise<string[]>;
51
+ export type { HostResolver } from "../net/ip-guard.js";
50
52
  /**
51
53
  * Injectable dependencies for the web tools. Defaults wire the real `fetch` and
52
54
  * DNS; tests pass spies/fakes. No global is ever patched.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {