@cruxy/cli 0.26.0 → 0.28.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/tools/file/apply-patch.js +12 -8
- package/dist/tools/file/edit-file.d.ts +0 -2
- package/dist/tools/file/edit-file.js +10 -19
- package/dist/tools/file/match.d.ts +43 -0
- package/dist/tools/file/match.js +127 -0
- 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
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolveToolPath } from "./paths.js";
|
|
4
|
-
|
|
5
|
-
export function countOccurrences(haystack, needle) {
|
|
6
|
-
let count = 0;
|
|
7
|
-
let i = haystack.indexOf(needle);
|
|
8
|
-
while (i !== -1) {
|
|
9
|
-
count++;
|
|
10
|
-
i = haystack.indexOf(needle, i + needle.length);
|
|
11
|
-
}
|
|
12
|
-
return count;
|
|
13
|
-
}
|
|
4
|
+
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
14
5
|
/**
|
|
15
6
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
16
7
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
@@ -47,14 +38,14 @@ export const editFileTool = {
|
|
|
47
38
|
}
|
|
48
39
|
return { ok: false, error: err.message };
|
|
49
40
|
}
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
41
|
+
const match = findMatch(content, input.old_str);
|
|
42
|
+
if (match.kind === "none") {
|
|
52
43
|
return { ok: false, error: `old_str not found in ${input.path}` };
|
|
53
44
|
}
|
|
54
|
-
if (
|
|
45
|
+
if (match.kind === "ambiguous") {
|
|
55
46
|
return {
|
|
56
47
|
ok: false,
|
|
57
|
-
error: `old_str not unique (${
|
|
48
|
+
error: `old_str not unique (${match.count} matches${tierLabel(match.tier)}); add surrounding context to disambiguate`,
|
|
58
49
|
};
|
|
59
50
|
}
|
|
60
51
|
const decision = await ctx.requestApproval({
|
|
@@ -68,11 +59,11 @@ export const editFileTool = {
|
|
|
68
59
|
error: decision.feedback ?? `edit to ${input.path} denied`,
|
|
69
60
|
};
|
|
70
61
|
}
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
const updated = content.slice(0,
|
|
74
|
-
input.new_str +
|
|
75
|
-
content.slice(
|
|
62
|
+
// Splice the matched span by offset (avoids `$`-pattern interpretation) and
|
|
63
|
+
// re-encode new_str to the file's line ending so a CRLF file stays CRLF.
|
|
64
|
+
const updated = content.slice(0, match.start) +
|
|
65
|
+
applyEol(input.new_str, detectEol(content)) +
|
|
66
|
+
content.slice(match.end);
|
|
76
67
|
try {
|
|
77
68
|
await fs.writeFile(abs, updated, "utf8");
|
|
78
69
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `old_str` inside a file for edit_file / apply_patch.
|
|
3
|
+
*
|
|
4
|
+
* Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
|
|
5
|
+
* checkouts/editors) — a byte-exact `indexOf` then never matches and the model
|
|
6
|
+
* can't replace existing code. We match in ordered tiers, tightest first, and
|
|
7
|
+
* stop at the first tier that finds anything:
|
|
8
|
+
*
|
|
9
|
+
* 0. exact — raw bytes; every currently-working edit takes this path
|
|
10
|
+
* 1. eol — CRLF and lone CR treated as LF (comparison only)
|
|
11
|
+
* 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
|
|
12
|
+
*
|
|
13
|
+
* Looser tiers compare against a normalized copy but return offsets into the
|
|
14
|
+
* ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
|
|
15
|
+
* file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
|
|
16
|
+
* more than one match at a tier is a hard error, and we do not fall through to a
|
|
17
|
+
* looser tier (looser can only be more ambiguous, never less).
|
|
18
|
+
*/
|
|
19
|
+
export type Tier = "exact" | "eol" | "eol+trailws";
|
|
20
|
+
export type MatchResult = {
|
|
21
|
+
kind: "found";
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "ambiguous";
|
|
26
|
+
count: number;
|
|
27
|
+
tier: Tier;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "none";
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Find the single occurrence of `oldStr` in `content`, tolerating line-ending
|
|
33
|
+
* and trailing-whitespace differences. Returns original-string byte offsets on
|
|
34
|
+
* a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
|
|
35
|
+
* `none` if no tier matched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findMatch(content: string, oldStr: string): MatchResult;
|
|
38
|
+
/** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
|
|
39
|
+
export declare function detectEol(content: string): "\r\n" | "\n";
|
|
40
|
+
/** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
|
|
41
|
+
export declare function applyEol(text: string, eol: "\r\n" | "\n"): string;
|
|
42
|
+
/** Human phrase for the tier named in an ambiguity error. */
|
|
43
|
+
export declare function tierLabel(tier: Tier): string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `old_str` inside a file for edit_file / apply_patch.
|
|
3
|
+
*
|
|
4
|
+
* Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
|
|
5
|
+
* checkouts/editors) — a byte-exact `indexOf` then never matches and the model
|
|
6
|
+
* can't replace existing code. We match in ordered tiers, tightest first, and
|
|
7
|
+
* stop at the first tier that finds anything:
|
|
8
|
+
*
|
|
9
|
+
* 0. exact — raw bytes; every currently-working edit takes this path
|
|
10
|
+
* 1. eol — CRLF and lone CR treated as LF (comparison only)
|
|
11
|
+
* 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
|
|
12
|
+
*
|
|
13
|
+
* Looser tiers compare against a normalized copy but return offsets into the
|
|
14
|
+
* ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
|
|
15
|
+
* file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
|
|
16
|
+
* more than one match at a tier is a hard error, and we do not fall through to a
|
|
17
|
+
* looser tier (looser can only be more ambiguous, never less).
|
|
18
|
+
*/
|
|
19
|
+
const TIERS = ["exact", "eol", "eol+trailws"];
|
|
20
|
+
/**
|
|
21
|
+
* Find the single occurrence of `oldStr` in `content`, tolerating line-ending
|
|
22
|
+
* and trailing-whitespace differences. Returns original-string byte offsets on
|
|
23
|
+
* a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
|
|
24
|
+
* `none` if no tier matched.
|
|
25
|
+
*/
|
|
26
|
+
export function findMatch(content, oldStr) {
|
|
27
|
+
for (const tier of TIERS) {
|
|
28
|
+
const occ = occurrences(content, oldStr, tier);
|
|
29
|
+
if (occ.length === 1) {
|
|
30
|
+
return { kind: "found", start: occ[0].start, end: occ[0].end };
|
|
31
|
+
}
|
|
32
|
+
if (occ.length > 1) {
|
|
33
|
+
return { kind: "ambiguous", count: occ.length, tier };
|
|
34
|
+
}
|
|
35
|
+
// 0 matches → try the next, looser tier.
|
|
36
|
+
}
|
|
37
|
+
return { kind: "none" };
|
|
38
|
+
}
|
|
39
|
+
/** Non-overlapping occurrences of `oldStr` under `tier`, as original offsets. */
|
|
40
|
+
function occurrences(content, oldStr, tier) {
|
|
41
|
+
const out = [];
|
|
42
|
+
if (tier === "exact") {
|
|
43
|
+
let i = content.indexOf(oldStr);
|
|
44
|
+
while (i !== -1) {
|
|
45
|
+
out.push({ start: i, end: i + oldStr.length });
|
|
46
|
+
i = content.indexOf(oldStr, i + oldStr.length);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const { norm, map } = normalize(content, tier);
|
|
51
|
+
const { norm: needle } = normalize(oldStr, tier);
|
|
52
|
+
// An all-whitespace old_str can normalize to empty under eol+trailws; refuse
|
|
53
|
+
// to "match everywhere" rather than delete at an arbitrary point.
|
|
54
|
+
if (needle.length === 0)
|
|
55
|
+
return out;
|
|
56
|
+
let i = norm.indexOf(needle);
|
|
57
|
+
while (i !== -1) {
|
|
58
|
+
out.push({ start: map[i], end: map[i + needle.length] });
|
|
59
|
+
i = norm.indexOf(needle, i + needle.length);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a normalized view of `s` plus `map`, where `map[k]` is the original
|
|
65
|
+
* offset of the k-th normalized char and `map[norm.length]` is a sentinel
|
|
66
|
+
* (`s.length`). This lets a match found in normalized space splice back into
|
|
67
|
+
* the original bytes exactly.
|
|
68
|
+
*/
|
|
69
|
+
function normalize(s, tier) {
|
|
70
|
+
const stripTrail = tier === "eol+trailws";
|
|
71
|
+
let norm = "";
|
|
72
|
+
const map = [];
|
|
73
|
+
// Whitespace whose trailing-vs-not status isn't known yet: flushed when real
|
|
74
|
+
// content follows on the line, dropped when a newline / EOF follows.
|
|
75
|
+
const pending = [];
|
|
76
|
+
const emit = (ch, off) => {
|
|
77
|
+
norm += ch;
|
|
78
|
+
map.push(off);
|
|
79
|
+
};
|
|
80
|
+
const flushPending = () => {
|
|
81
|
+
for (const p of pending)
|
|
82
|
+
emit(p.ch, p.off);
|
|
83
|
+
pending.length = 0;
|
|
84
|
+
};
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < s.length) {
|
|
87
|
+
const c = s[i];
|
|
88
|
+
if (c === "\r" || c === "\n") {
|
|
89
|
+
pending.length = 0; // any pending whitespace was trailing → drop it
|
|
90
|
+
emit("\n", i);
|
|
91
|
+
i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (stripTrail && (c === " " || c === "\t")) {
|
|
95
|
+
pending.push({ ch: c, off: i });
|
|
96
|
+
i += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
flushPending();
|
|
100
|
+
emit(c, i);
|
|
101
|
+
i += 1;
|
|
102
|
+
}
|
|
103
|
+
// Trailing whitespace at end-of-string (in `pending`) is intentionally dropped.
|
|
104
|
+
map.push(s.length);
|
|
105
|
+
return { norm, map };
|
|
106
|
+
}
|
|
107
|
+
/** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
|
|
108
|
+
export function detectEol(content) {
|
|
109
|
+
const i = content.indexOf("\n");
|
|
110
|
+
return i > 0 && content[i - 1] === "\r" ? "\r\n" : "\n";
|
|
111
|
+
}
|
|
112
|
+
/** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
|
|
113
|
+
export function applyEol(text, eol) {
|
|
114
|
+
const lf = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
115
|
+
return eol === "\r\n" ? lf.replace(/\n/g, "\r\n") : lf;
|
|
116
|
+
}
|
|
117
|
+
/** Human phrase for the tier named in an ambiguity error. */
|
|
118
|
+
export function tierLabel(tier) {
|
|
119
|
+
switch (tier) {
|
|
120
|
+
case "exact":
|
|
121
|
+
return "";
|
|
122
|
+
case "eol":
|
|
123
|
+
return " after end-of-line normalization";
|
|
124
|
+
case "eol+trailws":
|
|
125
|
+
return " after end-of-line + trailing-whitespace normalization";
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/web/ssrf.d.ts
CHANGED
|
@@ -17,34 +17,20 @@ import type { HostResolver } from "./types.js";
|
|
|
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 declare const defaultResolveHost: HostResolver;
|
|
23
|
-
/** Thrown when a URL is refused pre-dispatch; carries a human reason. */
|
|
24
|
-
export declare class BlockedHostError extends Error {
|
|
25
|
-
}
|
|
26
|
-
/** Thrown when the host could not be resolved (a network failure, not a block). */
|
|
27
|
-
export declare class HostUnresolvedError extends Error {
|
|
28
|
-
}
|
|
29
|
-
/** True if an address (v4 or v6) is in a range `web_fetch` must never reach. */
|
|
30
|
-
export declare function isBlockedAddress(addr: string): boolean;
|
|
31
|
-
/**
|
|
32
|
-
* A `dns.lookup`-compatible function that ignores the hostname and always hands
|
|
33
|
-
* back one of the pre-validated `addresses`. This is what pins a connection to the
|
|
34
|
-
* address the SSRF check already approved, defeating DNS rebinding: the socket can
|
|
35
|
-
* only reach a validated IP, never a value re-resolved at connect time.
|
|
36
|
-
*/
|
|
37
|
-
export declare function pinnedLookup(addresses: string[]): (_hostname: string, options: unknown, callback: (err: NodeJS.ErrnoException | null, address: string | {
|
|
38
|
-
address: string;
|
|
39
|
-
family: number;
|
|
40
|
-
}[], family?: number) => void) => void;
|
|
26
|
+
export { BlockedHostError, HostUnresolvedError, isBlockedAddress, defaultResolveHost, pinnedLookup, } from "../net/ip-guard.js";
|
|
41
27
|
/** An undici dispatcher whose connections are pinned to `addresses`. */
|
|
42
28
|
export declare function createPinnedDispatcher(addresses: string[]): Dispatcher;
|
|
43
29
|
/**
|
|
44
30
|
* Assert that `url` may be fetched and return the validated addresses to pin the
|
|
45
31
|
* connection to. Throws {@link BlockedHostError} for a bad scheme or a host
|
|
46
|
-
* resolving into a blocked range, or
|
|
47
|
-
*
|
|
32
|
+
* resolving into a blocked range, or `HostUnresolvedError` if the host cannot be
|
|
33
|
+
* resolved.
|
|
48
34
|
*
|
|
49
35
|
* The returned list is the exact set of addresses the caller must restrict the
|
|
50
36
|
* connection to (via {@link createPinnedDispatcher}). An empty list means "do not
|
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.
|