@chrischall/mcp-utils 2.15.1 → 3.0.1

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.
Files changed (79) hide show
  1. package/README.md +505 -26
  2. package/dist/auth/index.d.ts +25 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +105 -14
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/config/index.d.ts +13 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +3 -2
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/dates/index.d.ts +45 -5
  11. package/dist/dates/index.d.ts.map +1 -1
  12. package/dist/dates/index.js +64 -6
  13. package/dist/dates/index.js.map +1 -1
  14. package/dist/errors/index.d.ts +121 -7
  15. package/dist/errors/index.d.ts.map +1 -1
  16. package/dist/errors/index.js +154 -9
  17. package/dist/errors/index.js.map +1 -1
  18. package/dist/fs/output.d.ts +25 -8
  19. package/dist/fs/output.d.ts.map +1 -1
  20. package/dist/fs/output.js +27 -5
  21. package/dist/fs/output.js.map +1 -1
  22. package/dist/graphql/client.d.ts +8 -1
  23. package/dist/graphql/client.d.ts.map +1 -1
  24. package/dist/graphql/client.js +11 -3
  25. package/dist/graphql/client.js.map +1 -1
  26. package/dist/healthcheck/index.d.ts +11 -9
  27. package/dist/healthcheck/index.d.ts.map +1 -1
  28. package/dist/healthcheck/index.js +39 -23
  29. package/dist/healthcheck/index.js.map +1 -1
  30. package/dist/http/body-limit.d.ts +37 -0
  31. package/dist/http/body-limit.d.ts.map +1 -0
  32. package/dist/http/body-limit.js +86 -0
  33. package/dist/http/body-limit.js.map +1 -0
  34. package/dist/http/fetch-bounded.d.ts +91 -0
  35. package/dist/http/fetch-bounded.d.ts.map +1 -0
  36. package/dist/http/fetch-bounded.js +138 -0
  37. package/dist/http/fetch-bounded.js.map +1 -0
  38. package/dist/http/index.d.ts +231 -6
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +432 -56
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/response-cache.d.ts +36 -3
  43. package/dist/http/response-cache.d.ts.map +1 -1
  44. package/dist/http/response-cache.js +84 -8
  45. package/dist/http/response-cache.js.map +1 -1
  46. package/dist/http/throttle.d.ts +37 -4
  47. package/dist/http/throttle.d.ts.map +1 -1
  48. package/dist/http/throttle.js +65 -6
  49. package/dist/http/throttle.js.map +1 -1
  50. package/dist/http/url-safety.d.ts +142 -0
  51. package/dist/http/url-safety.d.ts.map +1 -0
  52. package/dist/http/url-safety.js +211 -0
  53. package/dist/http/url-safety.js.map +1 -0
  54. package/dist/netguard/index.d.ts +196 -0
  55. package/dist/netguard/index.d.ts.map +1 -0
  56. package/dist/netguard/index.js +365 -0
  57. package/dist/netguard/index.js.map +1 -0
  58. package/dist/server/confirm-env.d.ts +17 -8
  59. package/dist/server/confirm-env.d.ts.map +1 -1
  60. package/dist/server/confirm-env.js +22 -13
  61. package/dist/server/confirm-env.js.map +1 -1
  62. package/dist/server/confirm-write.js +1 -1
  63. package/dist/server/confirm-write.js.map +1 -1
  64. package/dist/server/index.d.ts +6 -2
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +24 -9
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/session/index.d.ts +6 -0
  69. package/dist/session/index.d.ts.map +1 -1
  70. package/dist/session/index.js +12 -1
  71. package/dist/session/index.js.map +1 -1
  72. package/dist/zod/index.d.ts +6 -0
  73. package/dist/zod/index.d.ts.map +1 -1
  74. package/dist/zod/index.js +8 -1
  75. package/dist/zod/index.js.map +1 -1
  76. package/docs/CLIENT-BEHAVIOUR.md +236 -0
  77. package/docs/CONFIRM-TOKEN.md +476 -0
  78. package/docs/MIGRATION-3.md +106 -0
  79. package/package.json +12 -2
@@ -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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"}