@chrischall/mcp-utils 2.15.1 → 3.0.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/README.md +493 -26
- package/dist/auth/index.d.ts +25 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +105 -14
- package/dist/auth/index.js.map +1 -1
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -2
- package/dist/config/index.js.map +1 -1
- package/dist/dates/index.d.ts +45 -5
- package/dist/dates/index.d.ts.map +1 -1
- package/dist/dates/index.js +64 -6
- package/dist/dates/index.js.map +1 -1
- package/dist/errors/index.d.ts +121 -7
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +154 -9
- package/dist/errors/index.js.map +1 -1
- package/dist/fs/output.d.ts +25 -8
- package/dist/fs/output.d.ts.map +1 -1
- package/dist/fs/output.js +27 -5
- package/dist/fs/output.js.map +1 -1
- package/dist/graphql/client.d.ts +8 -1
- package/dist/graphql/client.d.ts.map +1 -1
- package/dist/graphql/client.js +11 -3
- package/dist/graphql/client.js.map +1 -1
- package/dist/healthcheck/index.d.ts +11 -9
- package/dist/healthcheck/index.d.ts.map +1 -1
- package/dist/healthcheck/index.js +39 -23
- package/dist/healthcheck/index.js.map +1 -1
- package/dist/http/body-limit.d.ts +37 -0
- package/dist/http/body-limit.d.ts.map +1 -0
- package/dist/http/body-limit.js +86 -0
- package/dist/http/body-limit.js.map +1 -0
- package/dist/http/fetch-bounded.d.ts +91 -0
- package/dist/http/fetch-bounded.d.ts.map +1 -0
- package/dist/http/fetch-bounded.js +138 -0
- package/dist/http/fetch-bounded.js.map +1 -0
- package/dist/http/index.d.ts +231 -6
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +432 -56
- package/dist/http/index.js.map +1 -1
- package/dist/http/response-cache.d.ts +36 -3
- package/dist/http/response-cache.d.ts.map +1 -1
- package/dist/http/response-cache.js +84 -8
- package/dist/http/response-cache.js.map +1 -1
- package/dist/http/throttle.d.ts +37 -4
- package/dist/http/throttle.d.ts.map +1 -1
- package/dist/http/throttle.js +65 -6
- package/dist/http/throttle.js.map +1 -1
- package/dist/http/url-safety.d.ts +142 -0
- package/dist/http/url-safety.d.ts.map +1 -0
- package/dist/http/url-safety.js +211 -0
- package/dist/http/url-safety.js.map +1 -0
- package/dist/netguard/index.d.ts +196 -0
- package/dist/netguard/index.d.ts.map +1 -0
- package/dist/netguard/index.js +365 -0
- package/dist/netguard/index.js.map +1 -0
- package/dist/server/confirm-env.d.ts +17 -8
- package/dist/server/confirm-env.d.ts.map +1 -1
- package/dist/server/confirm-env.js +22 -13
- package/dist/server/confirm-env.js.map +1 -1
- package/dist/server/confirm-write.js +1 -1
- package/dist/server/confirm-write.js.map +1 -1
- package/dist/server/index.d.ts +6 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +24 -9
- package/dist/server/index.js.map +1 -1
- package/dist/session/index.d.ts +6 -0
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +12 -1
- package/dist/session/index.js.map +1 -1
- package/dist/zod/index.d.ts +6 -0
- package/dist/zod/index.d.ts.map +1 -1
- package/dist/zod/index.js +8 -1
- package/dist/zod/index.js.map +1 -1
- package/package.json +10 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-safety atoms: confine where a credential-bearing request can go.
|
|
3
|
+
*
|
|
4
|
+
* Fleet audit 2026-09, clusters 5 and 6.
|
|
5
|
+
*
|
|
6
|
+
* Cluster 5 (alltrails, compass, eventbrite, flightaware, freshbooks, homes,
|
|
7
|
+
* redfin, zillow): a tool argument interpolated into an API path steered an
|
|
8
|
+
* authenticated GET somewhere else on the same host. The shared root cause is
|
|
9
|
+
* that `encodeURIComponent('..') === '..'`, and URL normalisation then
|
|
10
|
+
* resolves the dot segment — so even a repo that encoded every value (and
|
|
11
|
+
* eventbrite forgot to on three tools) was exposed. {@link apiPath} encodes
|
|
12
|
+
* each value AND refuses a value that is a dot segment; `createApiClient`
|
|
13
|
+
* refuses any path that normalisation would rewrite ({@link findPathHazard}),
|
|
14
|
+
* so a consumer that never adopts `apiPath` is covered too.
|
|
15
|
+
*
|
|
16
|
+
* Cluster 6 (groupon, ioffice, myhotlunchbox, schoolpass, workday,
|
|
17
|
+
* accessoticketing, housecallpro, infinitecampus, flightaware, thumbtack,
|
|
18
|
+
* gemini): a base-URL env override or a link taken from a tool / the upstream
|
|
19
|
+
* was used without checking its scheme or host, so a session cookie or a
|
|
20
|
+
* password could be sent over http or to any host. {@link readOriginEnv} and
|
|
21
|
+
* {@link assertAllowedUrl} are the one check; `createApiClient`'s
|
|
22
|
+
* `redirect: 'same-origin'` keeps a redirect from carrying the bearer off the
|
|
23
|
+
* base origin.
|
|
24
|
+
*
|
|
25
|
+
* Errors here are value-free where the value could be a secret (an env var)
|
|
26
|
+
* and name only the host where the value is a link (whose path and query may
|
|
27
|
+
* carry a token).
|
|
28
|
+
*/
|
|
29
|
+
import { McpToolError } from '../errors/index.js';
|
|
30
|
+
import type { EnvSource } from '../config/index.js';
|
|
31
|
+
/**
|
|
32
|
+
* Why URL normalisation would rewrite the path portion of `path` (everything
|
|
33
|
+
* before the first `?` or `#`), or `undefined` when it would not: a dot
|
|
34
|
+
* segment (`.`, `..`, `%2e%2e` and the mixed spellings), a backslash (which
|
|
35
|
+
* special schemes treat as `/`), a C0 control character (U+0000-U+001F) or
|
|
36
|
+
* DEL, or a trailing space. The WHATWG URL parser deletes tab, LF and CR
|
|
37
|
+
* anywhere, and strips leading and trailing C0 controls and spaces from the
|
|
38
|
+
* whole URL, before it resolves dot segments — so `.\t.` becomes `..`, and
|
|
39
|
+
* `/x/.. ` (with no query after it) resolves to the parent. The query and
|
|
40
|
+
* fragment are not judged.
|
|
41
|
+
*
|
|
42
|
+
* `createApiClient` refuses a path for which this returns a reason; exported
|
|
43
|
+
* for hand-rolled clients that build URLs themselves.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findPathHazard(path: string): 'dot segment' | 'backslash' | 'control character' | 'trailing space' | undefined;
|
|
46
|
+
/** A value {@link apiPath} accepts as one path segment. */
|
|
47
|
+
export type ApiPathValue = string | number | bigint;
|
|
48
|
+
/**
|
|
49
|
+
* Tagged template that builds an API path from trusted literal text and
|
|
50
|
+
* untrusted values, encoding each value as exactly ONE path segment:
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* api.fetchJson('GET', apiPath`/trails/${id}/reviews`);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Each value goes through `encodeURIComponent` (so `/`, `?`, `#`, `\` and `%`
|
|
57
|
+
* cannot add structure), and a value that is empty, `.` or `..` throws a
|
|
58
|
+
* `TypeError` — `encodeURIComponent` leaves dots alone, so `..` would
|
|
59
|
+
* otherwise survive encoding and be resolved by the URL parser. Only strings,
|
|
60
|
+
* finite numbers and bigints are accepted; anything else is a programming
|
|
61
|
+
* error and throws.
|
|
62
|
+
*
|
|
63
|
+
* The literal parts are not touched: they are the author's own.
|
|
64
|
+
*/
|
|
65
|
+
export declare function apiPath(strings: TemplateStringsArray, ...values: ApiPathValue[]): string;
|
|
66
|
+
/**
|
|
67
|
+
* Thrown by {@link readOriginEnv} and {@link assertAllowedUrl} when a URL is
|
|
68
|
+
* refused. `reason` is a stable code for branching; the message never quotes
|
|
69
|
+
* an env value, and for a link names only its host.
|
|
70
|
+
*/
|
|
71
|
+
export declare class UrlNotAllowedError extends McpToolError {
|
|
72
|
+
readonly reason: 'invalid' | 'scheme' | 'insecure' | 'userinfo' | 'not-origin' | 'host';
|
|
73
|
+
constructor(message: string, reason: UrlNotAllowedError['reason']);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A host allowlist entry: an exact hostname (case-insensitive), `*.suffix`
|
|
77
|
+
* for any subdomain of `suffix` (not `suffix` itself), or a RegExp tested
|
|
78
|
+
* against the lower-case hostname — anchor it (`/^eu\d\.example\.com$/`).
|
|
79
|
+
*/
|
|
80
|
+
export type AllowedHost = string | RegExp;
|
|
81
|
+
/** Scheme rules shared by {@link readOriginEnv} and {@link assertAllowedUrl}. */
|
|
82
|
+
export interface SchemeRules {
|
|
83
|
+
/** Require `https:`. Default `true`. `false` allows `http:` to any host. */
|
|
84
|
+
requireHttps?: boolean;
|
|
85
|
+
/** With `requireHttps`, still allow `http:` to `localhost`, `127.0.0.0/8` and `[::1]` (local dev). Default `false`. */
|
|
86
|
+
allowHttpLoopback?: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Options for {@link readOriginEnv}. */
|
|
89
|
+
export interface ReadOriginEnvOptions extends SchemeRules {
|
|
90
|
+
/** The source to read from. Defaults to `process.env`. */
|
|
91
|
+
env?: EnvSource;
|
|
92
|
+
/** Used when the variable is unset; checked by the same rules. */
|
|
93
|
+
default?: string;
|
|
94
|
+
/** When set, the host must match one of these. */
|
|
95
|
+
allowHosts?: readonly AllowedHost[];
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Read a base-URL override from the environment and return its origin
|
|
99
|
+
* (`https://host[:port]`, lower-cased, no trailing slash), hardened like
|
|
100
|
+
* {@link readEnvVar} (blank / `'null'` / `${...}` placeholders are unset).
|
|
101
|
+
*
|
|
102
|
+
* Accepts a bare `host[:port]` (https is assumed) or an origin URL. Refuses
|
|
103
|
+
* with {@link UrlNotAllowedError} — naming only the variable, never its value —
|
|
104
|
+
* a value that:
|
|
105
|
+
* - is not http(s), or is `http:` while `requireHttps` (default `true`) holds
|
|
106
|
+
* (loopback excepted under `allowHttpLoopback`);
|
|
107
|
+
* - carries userinfo;
|
|
108
|
+
* - has a path, query or fragment (so a mangled `https://https://host` is
|
|
109
|
+
* refused instead of becoming host `https`);
|
|
110
|
+
* - names a host outside `allowHosts`, when given.
|
|
111
|
+
*
|
|
112
|
+
* Returns `opts.default` (checked by the same rules) or `undefined` when
|
|
113
|
+
* unset.
|
|
114
|
+
*/
|
|
115
|
+
export declare function readOriginEnv(key: string, opts: ReadOriginEnvOptions & {
|
|
116
|
+
default: string;
|
|
117
|
+
}): string;
|
|
118
|
+
export declare function readOriginEnv(key: string, opts?: ReadOriginEnvOptions): string | undefined;
|
|
119
|
+
/** Options for {@link assertAllowedUrl}. */
|
|
120
|
+
export interface AssertAllowedUrlOptions extends SchemeRules {
|
|
121
|
+
/** Hosts the URL may point at. Must be non-empty. */
|
|
122
|
+
allowHosts: readonly AllowedHost[];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Check a URL that came from a tool argument or an upstream response before
|
|
126
|
+
* sending anything — a cookie, a token, a password — to it. Returns the
|
|
127
|
+
* parsed `URL`; throws {@link UrlNotAllowedError} (naming the host, never the
|
|
128
|
+
* path or query) when it is not http(s), is `http:` while `requireHttps`
|
|
129
|
+
* (default `true`) holds, carries userinfo, or its host is not in
|
|
130
|
+
* `allowHosts`. Re-check every redirect hop you follow by hand.
|
|
131
|
+
*/
|
|
132
|
+
export declare function assertAllowedUrl(url: string | URL, opts: AssertAllowedUrlOptions): URL;
|
|
133
|
+
/**
|
|
134
|
+
* Thrown by `createApiClient({ redirect: 'same-origin' })` when a redirect
|
|
135
|
+
* points off the base origin (or adds userinfo), or the hop limit is reached.
|
|
136
|
+
* Nothing is sent to the refused target. The message names only the target's
|
|
137
|
+
* origin.
|
|
138
|
+
*/
|
|
139
|
+
export declare class RedirectRefusedError extends McpToolError {
|
|
140
|
+
constructor(message: string);
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=url-safety.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-safety.d.ts","sourceRoot":"","sources":["../../src/http/url-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAkBpD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,GACX,aAAa,GAAG,WAAW,GAAG,mBAAmB,GAAG,gBAAgB,GAAG,SAAS,CAOlF;AAED,2DAA2D;AAC3D,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,oBAAoB,EAAE,GAAG,MAAM,EAAE,YAAY,EAAE,GAAG,MAAM,CAgBxF;AAMD;;;;GAIG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;IAClD,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,UAAU,GAAG,UAAU,GAAG,YAAY,GAAG,MAAM,CAAC;IAExF,YAAY,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,CAAC,QAAQ,CAAC,EAIhE;CACF;AAED;;;;GAIG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,CAAC;AAE1C,iFAAiF;AACjF,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,uHAAuH;IACvH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAsCD,yCAAyC;AACzC,MAAM,WAAW,oBAAqB,SAAQ,WAAW;IACvD,0DAA0D;IAC1D,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kDAAkD;IAClD,UAAU,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;CACrC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,oBAAoB,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAAC;AACrG,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,oBAAoB,GAAG,MAAM,GAAG,SAAS,CAAC;AAuB5F,4CAA4C;AAC5C,MAAM,WAAW,uBAAwB,SAAQ,WAAW;IAC1D,qDAAqD;IACrD,UAAU,EAAE,SAAS,WAAW,EAAE,CAAC;CACpC;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,EAAE,uBAAuB,GAAG,GAAG,CAUtF;AAMD;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;IACpD,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-safety atoms: confine where a credential-bearing request can go.
|
|
3
|
+
*
|
|
4
|
+
* Fleet audit 2026-09, clusters 5 and 6.
|
|
5
|
+
*
|
|
6
|
+
* Cluster 5 (alltrails, compass, eventbrite, flightaware, freshbooks, homes,
|
|
7
|
+
* redfin, zillow): a tool argument interpolated into an API path steered an
|
|
8
|
+
* authenticated GET somewhere else on the same host. The shared root cause is
|
|
9
|
+
* that `encodeURIComponent('..') === '..'`, and URL normalisation then
|
|
10
|
+
* resolves the dot segment — so even a repo that encoded every value (and
|
|
11
|
+
* eventbrite forgot to on three tools) was exposed. {@link apiPath} encodes
|
|
12
|
+
* each value AND refuses a value that is a dot segment; `createApiClient`
|
|
13
|
+
* refuses any path that normalisation would rewrite ({@link findPathHazard}),
|
|
14
|
+
* so a consumer that never adopts `apiPath` is covered too.
|
|
15
|
+
*
|
|
16
|
+
* Cluster 6 (groupon, ioffice, myhotlunchbox, schoolpass, workday,
|
|
17
|
+
* accessoticketing, housecallpro, infinitecampus, flightaware, thumbtack,
|
|
18
|
+
* gemini): a base-URL env override or a link taken from a tool / the upstream
|
|
19
|
+
* was used without checking its scheme or host, so a session cookie or a
|
|
20
|
+
* password could be sent over http or to any host. {@link readOriginEnv} and
|
|
21
|
+
* {@link assertAllowedUrl} are the one check; `createApiClient`'s
|
|
22
|
+
* `redirect: 'same-origin'` keeps a redirect from carrying the bearer off the
|
|
23
|
+
* base origin.
|
|
24
|
+
*
|
|
25
|
+
* Errors here are value-free where the value could be a secret (an env var)
|
|
26
|
+
* and name only the host where the value is a link (whose path and query may
|
|
27
|
+
* carry a token).
|
|
28
|
+
*/
|
|
29
|
+
import { McpToolError } from '../errors/index.js';
|
|
30
|
+
import { readEnvVar } from '../config/index.js';
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Dot segments
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/** True for a WHATWG single- or double-dot path segment, in any `%2e` spelling. */
|
|
35
|
+
function isDotSegment(segment) {
|
|
36
|
+
const s = segment.replace(/%2e/gi, '.');
|
|
37
|
+
return s === '.' || s === '..';
|
|
38
|
+
}
|
|
39
|
+
/** `path` up to (not including) the first `?` or `#`. */
|
|
40
|
+
function pathPartOf(path) {
|
|
41
|
+
const end = path.search(/[?#]/);
|
|
42
|
+
return end === -1 ? path : path.slice(0, end);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Why URL normalisation would rewrite the path portion of `path` (everything
|
|
46
|
+
* before the first `?` or `#`), or `undefined` when it would not: a dot
|
|
47
|
+
* segment (`.`, `..`, `%2e%2e` and the mixed spellings), a backslash (which
|
|
48
|
+
* special schemes treat as `/`), a C0 control character (U+0000-U+001F) or
|
|
49
|
+
* DEL, or a trailing space. The WHATWG URL parser deletes tab, LF and CR
|
|
50
|
+
* anywhere, and strips leading and trailing C0 controls and spaces from the
|
|
51
|
+
* whole URL, before it resolves dot segments — so `.\t.` becomes `..`, and
|
|
52
|
+
* `/x/.. ` (with no query after it) resolves to the parent. The query and
|
|
53
|
+
* fragment are not judged.
|
|
54
|
+
*
|
|
55
|
+
* `createApiClient` refuses a path for which this returns a reason; exported
|
|
56
|
+
* for hand-rolled clients that build URLs themselves.
|
|
57
|
+
*/
|
|
58
|
+
export function findPathHazard(path) {
|
|
59
|
+
const pathPart = pathPartOf(path);
|
|
60
|
+
if (/[\x00-\x1f\x7f]/.test(pathPart))
|
|
61
|
+
return 'control character';
|
|
62
|
+
if (pathPart.endsWith(' '))
|
|
63
|
+
return 'trailing space';
|
|
64
|
+
if (pathPart.includes('\\'))
|
|
65
|
+
return 'backslash';
|
|
66
|
+
if (pathPart.split('/').some(isDotSegment))
|
|
67
|
+
return 'dot segment';
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Tagged template that builds an API path from trusted literal text and
|
|
72
|
+
* untrusted values, encoding each value as exactly ONE path segment:
|
|
73
|
+
*
|
|
74
|
+
* ```ts
|
|
75
|
+
* api.fetchJson('GET', apiPath`/trails/${id}/reviews`);
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* Each value goes through `encodeURIComponent` (so `/`, `?`, `#`, `\` and `%`
|
|
79
|
+
* cannot add structure), and a value that is empty, `.` or `..` throws a
|
|
80
|
+
* `TypeError` — `encodeURIComponent` leaves dots alone, so `..` would
|
|
81
|
+
* otherwise survive encoding and be resolved by the URL parser. Only strings,
|
|
82
|
+
* finite numbers and bigints are accepted; anything else is a programming
|
|
83
|
+
* error and throws.
|
|
84
|
+
*
|
|
85
|
+
* The literal parts are not touched: they are the author's own.
|
|
86
|
+
*/
|
|
87
|
+
export function apiPath(strings, ...values) {
|
|
88
|
+
let out = strings[0] ?? '';
|
|
89
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
90
|
+
const v = values[i];
|
|
91
|
+
let text;
|
|
92
|
+
if (typeof v === 'string')
|
|
93
|
+
text = v;
|
|
94
|
+
else if ((typeof v === 'number' && Number.isFinite(v)) || typeof v === 'bigint')
|
|
95
|
+
text = String(v);
|
|
96
|
+
else
|
|
97
|
+
throw new TypeError(`apiPath: value ${i + 1} must be a string, a finite number or a bigint.`);
|
|
98
|
+
if (text === '' || text === '.' || text === '..') {
|
|
99
|
+
throw new TypeError(`apiPath: value ${i + 1} is ${text === '' ? 'empty' : `the dot segment "${text}"`}, which cannot be a path segment.`);
|
|
100
|
+
}
|
|
101
|
+
out += encodeURIComponent(text) + (strings[i + 1] ?? '');
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Origins and allowed hosts
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
/**
|
|
109
|
+
* Thrown by {@link readOriginEnv} and {@link assertAllowedUrl} when a URL is
|
|
110
|
+
* refused. `reason` is a stable code for branching; the message never quotes
|
|
111
|
+
* an env value, and for a link names only its host.
|
|
112
|
+
*/
|
|
113
|
+
export class UrlNotAllowedError extends McpToolError {
|
|
114
|
+
reason;
|
|
115
|
+
constructor(message, reason) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = 'UrlNotAllowedError';
|
|
118
|
+
this.reason = reason;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isLoopback(hostname) {
|
|
122
|
+
return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
|
123
|
+
}
|
|
124
|
+
function hostAllowed(hostname, allowHosts) {
|
|
125
|
+
const host = hostname.toLowerCase();
|
|
126
|
+
return allowHosts.some((entry) => {
|
|
127
|
+
if (entry instanceof RegExp) {
|
|
128
|
+
entry.lastIndex = 0;
|
|
129
|
+
return entry.test(host);
|
|
130
|
+
}
|
|
131
|
+
const e = entry.toLowerCase();
|
|
132
|
+
if (e.startsWith('*.'))
|
|
133
|
+
return host.endsWith(e.slice(1)) && host.length > e.length - 1;
|
|
134
|
+
return host === e;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The checks both entry points share, on an already-parsed URL. `subject`
|
|
139
|
+
* names what was checked in the message (the env key, or the link's host).
|
|
140
|
+
*/
|
|
141
|
+
function checkUrl(u, subject, rules) {
|
|
142
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
143
|
+
throw new UrlNotAllowedError(`${subject} must be an http(s) URL.`, 'scheme');
|
|
144
|
+
}
|
|
145
|
+
if (u.protocol === 'http:' && (rules.requireHttps ?? true) && !(rules.allowHttpLoopback && isLoopback(u.hostname))) {
|
|
146
|
+
throw new UrlNotAllowedError(`${subject} must use https.`, 'insecure');
|
|
147
|
+
}
|
|
148
|
+
if (u.username !== '' || u.password !== '') {
|
|
149
|
+
throw new UrlNotAllowedError(`${subject} must not carry credentials (userinfo) in the URL.`, 'userinfo');
|
|
150
|
+
}
|
|
151
|
+
if (rules.allowHosts !== undefined && !hostAllowed(u.hostname, rules.allowHosts)) {
|
|
152
|
+
throw new UrlNotAllowedError(`${subject} is not an allowed host for this service.`, 'host');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function readOriginEnv(key, opts = {}) {
|
|
156
|
+
const raw = readEnvVar(key, opts.env ? { env: opts.env } : {});
|
|
157
|
+
const value = raw ?? opts.default;
|
|
158
|
+
if (value === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
const subject = raw === undefined ? `The default for ${key}` : `The environment variable ${key}`;
|
|
161
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(value) && !/^[^:/]+:\d+$/.test(value) ? value : `https://${value}`;
|
|
162
|
+
let u;
|
|
163
|
+
try {
|
|
164
|
+
u = new URL(withScheme);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
throw new UrlNotAllowedError(`${subject} is not a valid URL or host.`, 'invalid');
|
|
168
|
+
}
|
|
169
|
+
checkUrl(u, subject, opts);
|
|
170
|
+
if (u.pathname !== '/' || u.search !== '' || u.hash !== '' || /[?#]$/.test(value)) {
|
|
171
|
+
throw new UrlNotAllowedError(`${subject} must be an origin (scheme://host[:port]) with no path, query or fragment.`, 'not-origin');
|
|
172
|
+
}
|
|
173
|
+
return u.origin;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Check a URL that came from a tool argument or an upstream response before
|
|
177
|
+
* sending anything — a cookie, a token, a password — to it. Returns the
|
|
178
|
+
* parsed `URL`; throws {@link UrlNotAllowedError} (naming the host, never the
|
|
179
|
+
* path or query) when it is not http(s), is `http:` while `requireHttps`
|
|
180
|
+
* (default `true`) holds, carries userinfo, or its host is not in
|
|
181
|
+
* `allowHosts`. Re-check every redirect hop you follow by hand.
|
|
182
|
+
*/
|
|
183
|
+
export function assertAllowedUrl(url, opts) {
|
|
184
|
+
if (opts.allowHosts.length === 0)
|
|
185
|
+
throw new TypeError('assertAllowedUrl: allowHosts must not be empty.');
|
|
186
|
+
let u;
|
|
187
|
+
try {
|
|
188
|
+
u = new URL(String(url));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
throw new UrlNotAllowedError('The link is not a valid URL.', 'invalid');
|
|
192
|
+
}
|
|
193
|
+
checkUrl(u, `The link to ${u.host || u.protocol}`, opts);
|
|
194
|
+
return u;
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Redirects
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* Thrown by `createApiClient({ redirect: 'same-origin' })` when a redirect
|
|
201
|
+
* points off the base origin (or adds userinfo), or the hop limit is reached.
|
|
202
|
+
* Nothing is sent to the refused target. The message names only the target's
|
|
203
|
+
* origin.
|
|
204
|
+
*/
|
|
205
|
+
export class RedirectRefusedError extends McpToolError {
|
|
206
|
+
constructor(message) {
|
|
207
|
+
super(message);
|
|
208
|
+
this.name = 'RedirectRefusedError';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=url-safety.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"url-safety.js","sourceRoot":"","sources":["../../src/http/url-safety.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF guard for fetching a URL somebody else chose — a link from a tool
|
|
3
|
+
* argument, an email click-tracker, an `images_url`, every redirect any of
|
|
4
|
+
* them issue — so it can only reach the public internet.
|
|
5
|
+
*
|
|
6
|
+
* Fleet audit 2026-09-24 (chrischall/fleet-audit#1150). Three repos had grown
|
|
7
|
+
* three copies of the same table and none closed the gap the audit named:
|
|
8
|
+
*
|
|
9
|
+
* - accessoticketing-mcp `src/netguard.ts` resolved the name, checked every
|
|
10
|
+
* address, then let `fetch` resolve it AGAIN — so a DNS-rebinding name
|
|
11
|
+
* could answer public at check time and `169.254.169.254` at connect time
|
|
12
|
+
* (its own header comment said so) — and its redirect loop ran
|
|
13
|
+
* `hops <= MAX_REDIRECTS`, i.e. eleven requests for a limit of ten;
|
|
14
|
+
* - gemini-mcp `src/fetch-image.ts` judged only IP LITERALS ("closing
|
|
15
|
+
* [rebinding] needs resolve-then-pin, which neither Node's nor workerd's
|
|
16
|
+
* fetch exposes"), so any name pointing inward passed;
|
|
17
|
+
* - mcp-host `packages/core/src/egress.ts` had the most complete table, and
|
|
18
|
+
* the pin, but inside its own CONNECT proxy.
|
|
19
|
+
*
|
|
20
|
+
* This module is the one table and the pin:
|
|
21
|
+
*
|
|
22
|
+
* - {@link isPublicAddress} — one IPv4 + IPv6 block table (private,
|
|
23
|
+
* loopback, link-local / cloud metadata, CGNAT, ULA, multicast, reserved,
|
|
24
|
+
* documentation, and the forms that EMBED an IPv4 judged by that IPv4).
|
|
25
|
+
* Hand-parsed rather than `node:net`, and FAILS CLOSED on anything it
|
|
26
|
+
* cannot parse.
|
|
27
|
+
* - {@link createPublicOnlyDispatcher} — an undici `Agent` whose connector
|
|
28
|
+
* resolves the name itself (injectable resolver), refuses the connection
|
|
29
|
+
* with `UrlNotAllowedError` when ANY address is not public, and hands the
|
|
30
|
+
* socket exactly the addresses it vetted. There is no second resolution,
|
|
31
|
+
* so there is no lookup-to-connect window to rebind in. An IP-literal host
|
|
32
|
+
* (which Node never looks up) is judged directly.
|
|
33
|
+
* - {@link fetchPublic} — `fetch` through that dispatcher with MANUAL
|
|
34
|
+
* redirects: `assertAllowedUrl` on the first URL and on every hop, at most
|
|
35
|
+
* `maxRedirects` follow-up requests (so `maxRedirects + 1` requests in
|
|
36
|
+
* all), credential headers dropped on a cross-origin hop, one deadline
|
|
37
|
+
* over the whole exchange, and an optional byte cap.
|
|
38
|
+
*
|
|
39
|
+
* A SUBPATH (`@chrischall/mcp-utils/netguard`), never the root barrel: it
|
|
40
|
+
* imports `undici` (an optional peer), and only the few servers that fetch
|
|
41
|
+
* third-party URLs should have to install it.
|
|
42
|
+
*
|
|
43
|
+
* TLS still validates the certificate against the NAME (undici sets SNI /
|
|
44
|
+
* `servername` from the URL host), so pinning the address costs no
|
|
45
|
+
* certificate checking.
|
|
46
|
+
*/
|
|
47
|
+
import { Agent, buildConnector, Headers } from 'undici';
|
|
48
|
+
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
|
49
|
+
import { type AllowedHost } from '../http/index.js';
|
|
50
|
+
/**
|
|
51
|
+
* Is this textual IP address one the public internet routes?
|
|
52
|
+
*
|
|
53
|
+
* `false` for everything else — and for anything that is not a well-formed
|
|
54
|
+
* IPv4 dotted quad or IPv6 literal (a bracketed `[::1]`, a zone id
|
|
55
|
+
* `fe80::1%eth0`, the `127.1` shorthand): a guard that admits what it does not
|
|
56
|
+
* understand is the wrong default for this control.
|
|
57
|
+
*
|
|
58
|
+
* The ranges:
|
|
59
|
+
*
|
|
60
|
+
* IPv4 0/8 this-network, 10/8 + 172.16/12 + 192.168/16 RFC 1918,
|
|
61
|
+
* 100.64/10 CGNAT, 127/8 loopback, 169.254/16 link-local (the cloud
|
|
62
|
+
* metadata service), 192.0.0/24 protocol assignments, 192.0.2/24 +
|
|
63
|
+
* 198.51.100/24 + 203.0.113/24 TEST-NET, 192.88.99/24 6to4 relay
|
|
64
|
+
* anycast, 198.18/15 benchmarking, 224/4 multicast, 240/4 reserved
|
|
65
|
+
* including broadcast.
|
|
66
|
+
* IPv6 ::/96 (unspecified, loopback, and the deprecated IPv4-compatible
|
|
67
|
+
* form — refused WHOLE, even around a public IPv4), 64:ff9b:1::/48
|
|
68
|
+
* local-use NAT64, 100::/64 discard, 2001::/32 Teredo, 2001:2::/48
|
|
69
|
+
* benchmarking, 2001:10::/28 + 2001:20::/28 ORCHID, 2001:db8::/32 +
|
|
70
|
+
* 3fff::/20 documentation, 5f00::/16 SRv6, fc00::/7 ULA (Fly's 6PN
|
|
71
|
+
* `fdaa::/16` lives here), fe80::/10 link-local, fec0::/10
|
|
72
|
+
* site-local, ff00::/8 multicast.
|
|
73
|
+
* Embedded IPv4 — `::ffff:a.b.c.d` (mapped, what a dual-stack resolver
|
|
74
|
+
* returns for an A record), `::ffff:0:a.b.c.d` (SIIT), 64:ff9b::/96
|
|
75
|
+
* (NAT64) and 2002::/16 (6to4) — judged by the IPv4 they carry, so
|
|
76
|
+
* `::ffff:7f00:1` is loopback and `::ffff:8.8.8.8` is public.
|
|
77
|
+
*
|
|
78
|
+
* Consolidates accessoticketing-mcp `isPrivateAddress`, gemini-mcp
|
|
79
|
+
* `isPrivateIpv4`/`isPrivateIpv6` and mcp-host `isPublicAddress`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function isPublicAddress(ip: string): boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Resolves a hostname to every address it has. Injectable so tests never
|
|
84
|
+
* touch DNS (the same shape as accessoticketing-mcp's `Lookup`).
|
|
85
|
+
*/
|
|
86
|
+
export type Resolver = (hostname: string) => Promise<string[]>;
|
|
87
|
+
/** The default {@link Resolver}: the OS resolver, every address, in resolver order. */
|
|
88
|
+
export declare const systemResolver: Resolver;
|
|
89
|
+
/** Options for {@link createPublicOnlyDispatcher}. */
|
|
90
|
+
export interface PublicOnlyDispatcherOptions {
|
|
91
|
+
/** How a name becomes addresses. Default {@link systemResolver}. */
|
|
92
|
+
resolve?: Resolver;
|
|
93
|
+
/**
|
|
94
|
+
* Which addresses may be connected to. Default {@link isPublicAddress}.
|
|
95
|
+
* Widen it only deliberately (a test server on loopback; an allowlisted
|
|
96
|
+
* internal range) — every address a name resolves to must pass.
|
|
97
|
+
*/
|
|
98
|
+
isAllowedAddress?: (ip: string) => boolean;
|
|
99
|
+
/** Passed through to undici's `Agent` (timeouts, pool sizes, …). `connect` is owned by this function. */
|
|
100
|
+
agent?: Omit<Agent.Options, 'connect'>;
|
|
101
|
+
/** Passed through to undici's `buildConnector` (TLS options, connect timeout, …). `lookup` is owned by this function. */
|
|
102
|
+
connect?: Omit<buildConnector.BuildOptions, 'lookup'>;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* An undici `Agent` that can only connect to public addresses.
|
|
106
|
+
*
|
|
107
|
+
* Its connector resolves each new connection's host with `resolve`, refuses
|
|
108
|
+
* the connection with `UrlNotAllowedError` (reason `'host'`, naming the host —
|
|
109
|
+
* never the address it resolved to) when the name resolves to nothing or ANY
|
|
110
|
+
* address fails `isAllowedAddress`, and gives the socket exactly the addresses
|
|
111
|
+
* it checked. Node never resolves the name a second time, which closes the
|
|
112
|
+
* lookup-to-connect DNS-rebinding window a check-then-`fetch` guard leaves
|
|
113
|
+
* open. An IP-literal host is judged as itself and never resolved.
|
|
114
|
+
*
|
|
115
|
+
* Use it as the `dispatcher` of undici's `fetch`/`request` (or via
|
|
116
|
+
* {@link fetchPublic}). Close it (`await agent.close()`) when done.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createPublicOnlyDispatcher(opts?: PublicOnlyDispatcherOptions): Agent;
|
|
119
|
+
/** Options for {@link fetchPublic}: undici `RequestInit` (minus what it owns) plus the guard's own. */
|
|
120
|
+
export interface FetchPublicOptions extends Omit<UndiciRequestInit, 'redirect' | 'dispatcher' | 'signal' | 'window'>, Pick<PublicOnlyDispatcherOptions, 'resolve' | 'isAllowedAddress'> {
|
|
121
|
+
/**
|
|
122
|
+
* Redirect follow-ups allowed — at most this many requests after the first,
|
|
123
|
+
* so `maxRedirects + 1` in all. A redirect past the limit is refused
|
|
124
|
+
* (`RedirectRefusedError`) without being requested. Default 10; 0 refuses
|
|
125
|
+
* every redirect.
|
|
126
|
+
*/
|
|
127
|
+
maxRedirects?: number;
|
|
128
|
+
/** One deadline over every hop and the body read. Omit for none. Throws `RequestTimeoutError`. */
|
|
129
|
+
timeoutMs?: number;
|
|
130
|
+
/** Byte cap on the final body; over it throws `ResponseTooLargeError`. Omit for none. */
|
|
131
|
+
maxBytes?: number;
|
|
132
|
+
/** The caller's cancellation; an abort rejects with its reason. */
|
|
133
|
+
signal?: AbortSignal;
|
|
134
|
+
/**
|
|
135
|
+
* The dispatcher to send through. Default: a fresh
|
|
136
|
+
* {@link createPublicOnlyDispatcher} built from `resolve` /
|
|
137
|
+
* `isAllowedAddress`, destroyed when the call settles. One you pass is used
|
|
138
|
+
* as-is and left open — it must be public-only itself.
|
|
139
|
+
*/
|
|
140
|
+
dispatcher?: Dispatcher;
|
|
141
|
+
/** Require `https:` on the first URL and every hop. Default `true` (as `assertAllowedUrl`). */
|
|
142
|
+
requireHttps?: boolean;
|
|
143
|
+
/** When given, every hop's host must match (as `assertAllowedUrl`'s `allowHosts`). */
|
|
144
|
+
allowHosts?: readonly AllowedHost[];
|
|
145
|
+
/**
|
|
146
|
+
* Asked about the first URL and every redirect target BEFORE it is
|
|
147
|
+
* requested; `true` returns `{ stopped: true, url, hops }` without fetching
|
|
148
|
+
* it — e.g. a click-tracker resolver stopping at the first link on its own
|
|
149
|
+
* service. The target has already passed `assertAllowedUrl`.
|
|
150
|
+
*/
|
|
151
|
+
stopBefore?: (url: URL) => boolean;
|
|
152
|
+
}
|
|
153
|
+
/** A {@link fetchPublic} that ran to a final response. */
|
|
154
|
+
export interface PublicFetchResponse {
|
|
155
|
+
stopped: false;
|
|
156
|
+
/** The URL of the final response (after redirects). */
|
|
157
|
+
url: string;
|
|
158
|
+
/** Redirects followed. */
|
|
159
|
+
hops: number;
|
|
160
|
+
status: number;
|
|
161
|
+
headers: Headers;
|
|
162
|
+
body: Uint8Array;
|
|
163
|
+
}
|
|
164
|
+
/** A {@link fetchPublic} that `stopBefore` ended before requesting `url`. */
|
|
165
|
+
export interface PublicFetchStopped {
|
|
166
|
+
stopped: true;
|
|
167
|
+
/** The URL `stopBefore` stopped at — not requested. */
|
|
168
|
+
url: string;
|
|
169
|
+
/** Redirects followed to reach it. */
|
|
170
|
+
hops: number;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* `fetch` a URL somebody else chose, reaching only public addresses.
|
|
174
|
+
*
|
|
175
|
+
* Every request goes through a {@link createPublicOnlyDispatcher} (so a name
|
|
176
|
+
* that resolves — or REBINDS — inward is refused at connect), redirects are
|
|
177
|
+
* followed by hand with `assertAllowedUrl` on each target and at most
|
|
178
|
+
* `maxRedirects` follow-ups, and credential headers (`authorization`,
|
|
179
|
+
* `cookie`, `proxy-authorization`) are dropped once a hop leaves the
|
|
180
|
+
* original origin. 303 (and 301/302 after a POST) becomes a body-less GET, as
|
|
181
|
+
* in the Fetch standard; 307/308 resend the method and body (a stream body
|
|
182
|
+
* cannot be resent — pass bytes or a string if the target may 307).
|
|
183
|
+
*
|
|
184
|
+
* Returns the final response's status, headers and body bytes (capped by
|
|
185
|
+
* `maxBytes`). A 3xx with no `Location` is a final response. Throws
|
|
186
|
+
* `UrlNotAllowedError` for a refused URL or address, `RedirectRefusedError`
|
|
187
|
+
* past the limit, `RequestTimeoutError` past `timeoutMs`,
|
|
188
|
+
* `ResponseTooLargeError` past `maxBytes`, the caller's abort reason, and an
|
|
189
|
+
* `McpToolError` of kind `'transport'` (naming only the host) when the host
|
|
190
|
+
* cannot be reached.
|
|
191
|
+
*/
|
|
192
|
+
export declare function fetchPublic(url: string | URL, opts: FetchPublicOptions & {
|
|
193
|
+
stopBefore: (url: URL) => boolean;
|
|
194
|
+
}): Promise<PublicFetchResponse | PublicFetchStopped>;
|
|
195
|
+
export declare function fetchPublic(url: string | URL, opts?: Omit<FetchPublicOptions, 'stopBefore'>): Promise<PublicFetchResponse>;
|
|
196
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/netguard/index.ts"],"names":[],"mappings":"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"}
|