@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/cli/commands/mcp.js +106 -7
- package/dist/config/credentials.d.ts +9 -0
- package/dist/config/credentials.js +29 -1
- package/dist/config/manager.js +30 -3
- package/dist/config/schema.d.ts +182 -8
- package/dist/config/schema.js +43 -5
- package/dist/errors/constructors.d.ts +29 -0
- package/dist/errors/constructors.js +69 -0
- package/dist/errors/types.d.ts +15 -0
- package/dist/errors/types.js +18 -0
- package/dist/mcp/http-transport.d.ts +89 -0
- package/dist/mcp/http-transport.js +299 -0
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/service.d.ts +19 -2
- package/dist/mcp/service.js +92 -20
- package/dist/mcp/trust-gate.d.ts +35 -11
- package/dist/mcp/trust-gate.js +87 -22
- package/dist/mcp/trust.d.ts +12 -2
- package/dist/mcp/trust.js +26 -2
- package/dist/mcp/types.d.ts +10 -0
- package/dist/mcp/url-guard.d.ts +48 -0
- package/dist/mcp/url-guard.js +62 -0
- package/dist/net/ip-guard.d.ts +55 -0
- package/dist/net/ip-guard.js +229 -0
- package/dist/render/index.d.ts +1 -0
- package/dist/render/index.js +1 -0
- package/dist/render/motion.d.ts +76 -0
- package/dist/render/motion.js +94 -0
- package/dist/render/tty-renderer.d.ts +17 -3
- package/dist/render/tty-renderer.js +58 -21
- package/dist/web/ssrf.d.ts +8 -22
- package/dist/web/ssrf.js +11 -183
- package/dist/web/types.d.ts +4 -2
- package/package.json +1 -1
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
|
-
|
|
22
|
-
export
|
|
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
|
|
192
|
-
*
|
|
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
|
-
|
|
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
|
}
|
package/dist/web/types.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|