mcp-from-openapi 2.5.0 → 2.5.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.
package/esm/index.mjs CHANGED
@@ -892,8 +892,15 @@ function isBlockedHostname(hostname, ssrf) {
892
892
  if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
893
893
  return isBlockedAddress(hostname);
894
894
  }
895
+ var SsrfResolverUnavailableError = class extends Error {
896
+ };
895
897
  var defaultLookup = async (hostname) => {
896
- const dns = await import("node:dns");
898
+ let dns;
899
+ try {
900
+ dns = await import("node:dns");
901
+ } catch {
902
+ throw new SsrfResolverUnavailableError("DNS resolution is unavailable on this runtime");
903
+ }
897
904
  return dns.promises.lookup(hostname, { all: true });
898
905
  };
899
906
  async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
@@ -915,43 +922,133 @@ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
915
922
  if (ssrf.blockedHosts.includes(hostname)) {
916
923
  throw new SsrfError(`Host "${hostname}" is blocked`, { url });
917
924
  }
918
- return;
925
+ return [];
919
926
  }
920
927
  if (isBlockedHostname(hostname, ssrf)) {
921
928
  throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
922
929
  }
923
- if (!isIpLiteral(hostname)) {
924
- let addresses;
925
- try {
926
- addresses = await lookup(hostname);
927
- } catch {
928
- return;
930
+ if (isIpLiteral(hostname)) {
931
+ return [];
932
+ }
933
+ let addresses;
934
+ try {
935
+ addresses = await lookup(hostname);
936
+ } catch (error) {
937
+ if (error instanceof SsrfResolverUnavailableError) {
938
+ return [];
929
939
  }
930
- for (const { address } of addresses) {
931
- if (isBlockedAddress(address)) {
932
- throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
933
- }
940
+ const message = error instanceof Error ? error.message : String(error);
941
+ throw new SsrfError(`Host "${hostname}" could not be resolved for SSRF validation: ${message}`, { url });
942
+ }
943
+ if (addresses.length === 0) {
944
+ throw new SsrfError(`Host "${hostname}" did not resolve to any address`, { url });
945
+ }
946
+ for (const { address } of addresses) {
947
+ if (isBlockedAddress(address)) {
948
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
934
949
  }
935
950
  }
951
+ return addresses;
936
952
  }
937
- async function safeFetch(url, opts) {
938
- const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
939
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
940
- if (typeof fetchImpl !== "function") {
953
+ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
954
+ async function loadNodeHttpModules() {
955
+ try {
956
+ const [http, https] = await Promise.all([import("node:http"), import("node:https")]);
957
+ return { http, https };
958
+ } catch {
959
+ return null;
960
+ }
961
+ }
962
+ function pickHttpModule(protocol, modules) {
963
+ return protocol === "https:" ? modules.https : modules.http;
964
+ }
965
+ function makePinnedLookup(pinned) {
966
+ return (_hostname, options, callback) => {
967
+ const done = typeof options === "function" ? options : callback;
968
+ const wantsAll = typeof options === "object" && options !== null && options.all === true;
969
+ if (wantsAll) {
970
+ done(
971
+ null,
972
+ pinned.map(({ address, family }) => ({ address, family }))
973
+ );
974
+ } else {
975
+ done(null, pinned[0].address, pinned[0].family);
976
+ }
977
+ };
978
+ }
979
+ var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
980
+ function nodePinnedTransport(modules) {
981
+ return (url, { headers, signal, pinned, maxBytes }) => new Promise((resolve, reject) => {
982
+ const limit = maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
983
+ const lib = pickHttpModule(new URL(url).protocol, modules);
984
+ const requestOptions = {
985
+ method: "GET",
986
+ signal,
987
+ headers: { ...headers, "accept-encoding": "identity" }
988
+ };
989
+ if (pinned.length > 0) {
990
+ requestOptions["lookup"] = makePinnedLookup(pinned);
991
+ }
992
+ const request = lib.request(url, requestOptions, (response) => {
993
+ const chunks = [];
994
+ let received = 0;
995
+ response.on("data", (chunk) => {
996
+ received += chunk.length;
997
+ if (received > limit) {
998
+ request.destroy();
999
+ reject(new SsrfError(`Response body exceeds ${limit} bytes`, { url }));
1000
+ return;
1001
+ }
1002
+ chunks.push(chunk);
1003
+ });
1004
+ response.on("end", () => {
1005
+ const status = response.statusCode;
1006
+ const responseHeaders = new Headers();
1007
+ const entries = Object.entries(response.headers);
1008
+ for (const [key, value] of entries) {
1009
+ if (Array.isArray(value)) {
1010
+ for (const item of value) responseHeaders.append(key, item);
1011
+ } else {
1012
+ responseHeaders.append(key, value);
1013
+ }
1014
+ }
1015
+ const body = NULL_BODY_STATUS.has(status) ? null : Buffer.concat(chunks);
1016
+ resolve(new Response(body, { status, statusText: response.statusMessage, headers: responseHeaders }));
1017
+ });
1018
+ response.on("error", reject);
1019
+ });
1020
+ request.on("error", reject);
1021
+ request.end();
1022
+ });
1023
+ }
1024
+ function fetchTransport(fetchImpl) {
1025
+ return (url, { headers, signal }) => fetchImpl(url, { headers, signal, redirect: "manual" });
1026
+ }
1027
+ async function selectTransport(opts, url) {
1028
+ if (opts.fetchImpl) {
1029
+ return fetchTransport(opts.fetchImpl);
1030
+ }
1031
+ const modules = await loadNodeHttpModules();
1032
+ if (!modules) {
1033
+ const platformFetch = globalThis.fetch;
1034
+ if (typeof platformFetch === "function") {
1035
+ return fetchTransport(platformFetch);
1036
+ }
941
1037
  throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
942
1038
  }
1039
+ return nodePinnedTransport(modules);
1040
+ }
1041
+ async function safeFetch(url, opts) {
1042
+ const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
1043
+ const transport = await selectTransport(opts, url);
943
1044
  let current = url;
944
1045
  for (let hop = 0; hop <= maxRedirects; hop++) {
945
- await assertUrlSafe(current, ssrf, lookup);
1046
+ const pinned = await assertUrlSafe(current, ssrf, lookup);
946
1047
  const controller = new AbortController();
947
1048
  const timer = setTimeout(() => controller.abort(), timeoutMs);
948
1049
  let response;
949
1050
  try {
950
- response = await fetchImpl(current, {
951
- headers,
952
- signal: controller.signal,
953
- redirect: "manual"
954
- });
1051
+ response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
955
1052
  } finally {
956
1053
  clearTimeout(timer);
957
1054
  }
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Production-ready library for converting OpenAPI specifications into MCP tool definitions",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "license": "Apache-2.0",
package/index.js CHANGED
@@ -953,8 +953,15 @@ function isBlockedHostname(hostname, ssrf) {
953
953
  if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
954
954
  return isBlockedAddress(hostname);
955
955
  }
956
+ var SsrfResolverUnavailableError = class extends Error {
957
+ };
956
958
  var defaultLookup = async (hostname) => {
957
- const dns = await import("node:dns");
959
+ let dns;
960
+ try {
961
+ dns = await import("node:dns");
962
+ } catch {
963
+ throw new SsrfResolverUnavailableError("DNS resolution is unavailable on this runtime");
964
+ }
958
965
  return dns.promises.lookup(hostname, { all: true });
959
966
  };
960
967
  async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
@@ -976,43 +983,133 @@ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
976
983
  if (ssrf.blockedHosts.includes(hostname)) {
977
984
  throw new SsrfError(`Host "${hostname}" is blocked`, { url });
978
985
  }
979
- return;
986
+ return [];
980
987
  }
981
988
  if (isBlockedHostname(hostname, ssrf)) {
982
989
  throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
983
990
  }
984
- if (!isIpLiteral(hostname)) {
985
- let addresses;
986
- try {
987
- addresses = await lookup(hostname);
988
- } catch {
989
- return;
991
+ if (isIpLiteral(hostname)) {
992
+ return [];
993
+ }
994
+ let addresses;
995
+ try {
996
+ addresses = await lookup(hostname);
997
+ } catch (error) {
998
+ if (error instanceof SsrfResolverUnavailableError) {
999
+ return [];
990
1000
  }
991
- for (const { address } of addresses) {
992
- if (isBlockedAddress(address)) {
993
- throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
994
- }
1001
+ const message = error instanceof Error ? error.message : String(error);
1002
+ throw new SsrfError(`Host "${hostname}" could not be resolved for SSRF validation: ${message}`, { url });
1003
+ }
1004
+ if (addresses.length === 0) {
1005
+ throw new SsrfError(`Host "${hostname}" did not resolve to any address`, { url });
1006
+ }
1007
+ for (const { address } of addresses) {
1008
+ if (isBlockedAddress(address)) {
1009
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
995
1010
  }
996
1011
  }
1012
+ return addresses;
997
1013
  }
998
- async function safeFetch(url, opts) {
999
- const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
1000
- const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
1001
- if (typeof fetchImpl !== "function") {
1014
+ var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
1015
+ async function loadNodeHttpModules() {
1016
+ try {
1017
+ const [http, https] = await Promise.all([import("node:http"), import("node:https")]);
1018
+ return { http, https };
1019
+ } catch {
1020
+ return null;
1021
+ }
1022
+ }
1023
+ function pickHttpModule(protocol, modules) {
1024
+ return protocol === "https:" ? modules.https : modules.http;
1025
+ }
1026
+ function makePinnedLookup(pinned) {
1027
+ return (_hostname, options, callback) => {
1028
+ const done = typeof options === "function" ? options : callback;
1029
+ const wantsAll = typeof options === "object" && options !== null && options.all === true;
1030
+ if (wantsAll) {
1031
+ done(
1032
+ null,
1033
+ pinned.map(({ address, family }) => ({ address, family }))
1034
+ );
1035
+ } else {
1036
+ done(null, pinned[0].address, pinned[0].family);
1037
+ }
1038
+ };
1039
+ }
1040
+ var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
1041
+ function nodePinnedTransport(modules) {
1042
+ return (url, { headers, signal, pinned, maxBytes }) => new Promise((resolve, reject) => {
1043
+ const limit = maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
1044
+ const lib = pickHttpModule(new URL(url).protocol, modules);
1045
+ const requestOptions = {
1046
+ method: "GET",
1047
+ signal,
1048
+ headers: { ...headers, "accept-encoding": "identity" }
1049
+ };
1050
+ if (pinned.length > 0) {
1051
+ requestOptions["lookup"] = makePinnedLookup(pinned);
1052
+ }
1053
+ const request = lib.request(url, requestOptions, (response) => {
1054
+ const chunks = [];
1055
+ let received = 0;
1056
+ response.on("data", (chunk) => {
1057
+ received += chunk.length;
1058
+ if (received > limit) {
1059
+ request.destroy();
1060
+ reject(new SsrfError(`Response body exceeds ${limit} bytes`, { url }));
1061
+ return;
1062
+ }
1063
+ chunks.push(chunk);
1064
+ });
1065
+ response.on("end", () => {
1066
+ const status = response.statusCode;
1067
+ const responseHeaders = new Headers();
1068
+ const entries = Object.entries(response.headers);
1069
+ for (const [key, value] of entries) {
1070
+ if (Array.isArray(value)) {
1071
+ for (const item of value) responseHeaders.append(key, item);
1072
+ } else {
1073
+ responseHeaders.append(key, value);
1074
+ }
1075
+ }
1076
+ const body = NULL_BODY_STATUS.has(status) ? null : Buffer.concat(chunks);
1077
+ resolve(new Response(body, { status, statusText: response.statusMessage, headers: responseHeaders }));
1078
+ });
1079
+ response.on("error", reject);
1080
+ });
1081
+ request.on("error", reject);
1082
+ request.end();
1083
+ });
1084
+ }
1085
+ function fetchTransport(fetchImpl) {
1086
+ return (url, { headers, signal }) => fetchImpl(url, { headers, signal, redirect: "manual" });
1087
+ }
1088
+ async function selectTransport(opts, url) {
1089
+ if (opts.fetchImpl) {
1090
+ return fetchTransport(opts.fetchImpl);
1091
+ }
1092
+ const modules = await loadNodeHttpModules();
1093
+ if (!modules) {
1094
+ const platformFetch = globalThis.fetch;
1095
+ if (typeof platformFetch === "function") {
1096
+ return fetchTransport(platformFetch);
1097
+ }
1002
1098
  throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
1003
1099
  }
1100
+ return nodePinnedTransport(modules);
1101
+ }
1102
+ async function safeFetch(url, opts) {
1103
+ const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
1104
+ const transport = await selectTransport(opts, url);
1004
1105
  let current = url;
1005
1106
  for (let hop = 0; hop <= maxRedirects; hop++) {
1006
- await assertUrlSafe(current, ssrf, lookup);
1107
+ const pinned = await assertUrlSafe(current, ssrf, lookup);
1007
1108
  const controller = new AbortController();
1008
1109
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1009
1110
  let response;
1010
1111
  try {
1011
- response = await fetchImpl(current, {
1012
- headers,
1013
- signal: controller.signal,
1014
- redirect: "manual"
1015
- });
1112
+ response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
1016
1113
  } finally {
1017
1114
  clearTimeout(timer);
1018
1115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.5.0",
3
+ "version": "2.5.1",
4
4
  "description": "Production-ready library for converting OpenAPI specifications into MCP tool definitions",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "license": "Apache-2.0",
package/ssrf.d.ts CHANGED
@@ -20,17 +20,25 @@
20
20
  * multicast / unspecified / reserved ranges and cloud-metadata endpoints;
21
21
  * - resolves the hostname (Node, via `node:dns`) and rejects if **any**
22
22
  * resolved address is internal — closing the DNS-name-to-internal bypass;
23
+ * - **pins the connection to the validated IP**: on Node, {@link safeFetch}
24
+ * connects through `node:http`/`node:https` with a custom `lookup` that
25
+ * returns the exact address {@link assertUrlSafe} just validated, so the
26
+ * guard and the socket share one DNS resolution. This closes the
27
+ * DNS-rebinding TOCTOU where the client would otherwise re-resolve the
28
+ * hostname at connect time and reach a different (internal) address. The
29
+ * original hostname is preserved for the `Host` header and TLS SNI;
23
30
  * - re-validates every redirect hop ({@link safeFetch}) instead of letting the
24
31
  * HTTP client follow 3xx blindly.
25
32
  *
26
- * Node-aware: DNS resolution lazily imports `node:dns` and is a no-op on
27
- * runtimes without it (Web/edge), where the literal-address checks still apply.
33
+ * Node-aware: DNS resolution lazily imports `node:dns` and IP pinning lazily
34
+ * imports `node:http`/`node:https`. On runtimes without them (Web/edge) the
35
+ * literal-address checks still apply and the fetch falls back to the platform
36
+ * `fetch` (best-effort, without connection pinning) — combine with an
37
+ * `allowedHosts` allow-list and network egress controls there.
28
38
  *
29
- * Residual: this does DNS resolve-then-fetch without connection-level IP
30
- * pinning, so a sub-second DNS-rebinding race (flip the record between the
31
- * validating resolve and the client's connect-time resolve) is not fully
32
- * eliminated. For fully-untrusted inputs, combine with an `allowedHosts`
33
- * allow-list and network egress controls.
39
+ * Fails closed: a genuine resolver error rejects the URL rather than proceeding
40
+ * unvalidated; only a runtime that has no resolver at all (edge) skips the DNS
41
+ * step ({@link SsrfResolverUnavailableError}).
34
42
  */
35
43
  import type { RefResolutionOptions } from './types';
36
44
  /** The subset of {@link RefResolutionOptions} relevant to address blocking. */
@@ -75,15 +83,33 @@ export declare function isBlockedAddress(host: string): boolean;
75
83
  * internal addresses are caught later, asynchronously, in {@link safeFetch}.
76
84
  */
77
85
  export declare function isBlockedHostname(hostname: string, ssrf: ResolvedSsrfOptions): boolean;
78
- /** Default DNS resolver: lazily loads `node:dns`; rejects on non-Node runtimes. */
86
+ /**
87
+ * Signals that no DNS resolver is available on the current runtime (e.g. a
88
+ * Web/edge isolate without `node:dns`). {@link assertUrlSafe} treats this as
89
+ * "cannot resolve, cannot pin" and proceeds on literal-address checks only,
90
+ * whereas a genuine resolver failure fails closed.
91
+ */
92
+ export declare class SsrfResolverUnavailableError extends Error {
93
+ }
94
+ /** Default DNS resolver: lazily loads `node:dns`; signals unavailability off-Node. */
79
95
  export declare const defaultLookup: SsrfHostLookup;
80
96
  /**
81
97
  * Validate that `url` is safe to fetch (spec URL or `$ref` target), throwing
82
98
  * {@link SsrfError} if not. Enforces http/https, the `allowedHosts` allow-list,
83
99
  * the internal-address denylist, and — for DNS names — resolves and rejects if
84
100
  * any resolved address is internal.
101
+ *
102
+ * Returns the validated resolved addresses so the caller can **pin** the
103
+ * connection to them ({@link safeFetch}), guaranteeing the socket connects to
104
+ * the exact IP that was validated rather than re-resolving the hostname. An
105
+ * empty array means "no pinning needed" (a literal IP, `allowInternalIPs`, or a
106
+ * runtime without a resolver).
107
+ *
108
+ * Fails closed: a genuine resolver error (or a name that resolves to no
109
+ * address) rejects the URL. Only {@link SsrfResolverUnavailableError} — no
110
+ * resolver on this runtime — is treated as best-effort and returns `[]`.
85
111
  */
86
- export declare function assertUrlSafe(url: string, ssrf: ResolvedSsrfOptions, lookup?: SsrfHostLookup): Promise<void>;
112
+ export declare function assertUrlSafe(url: string, ssrf: ResolvedSsrfOptions, lookup?: SsrfHostLookup): Promise<ResolvedAddress[]>;
87
113
  /** Options for {@link safeFetch}. */
88
114
  export interface SafeFetchOptions {
89
115
  headers?: Record<string, string>;
@@ -92,17 +118,53 @@ export interface SafeFetchOptions {
92
118
  followRedirects?: boolean;
93
119
  /** Max redirect hops before failing. @default 5 */
94
120
  maxRedirects?: number;
121
+ /** Max response body bytes before the request is aborted (Node transport). @default 10 MiB */
122
+ maxResponseBytes?: number;
95
123
  ssrf: ResolvedSsrfOptions;
96
124
  /** Injectable DNS resolver (tests). */
97
125
  lookup?: SsrfHostLookup;
98
- /** Injectable fetch implementation (tests / custom runtimes). */
126
+ /**
127
+ * Injectable fetch implementation (tests / custom runtimes). Providing this
128
+ * bypasses the Node connection-pinning transport, so the implementation is
129
+ * responsible for connecting to the validated address itself.
130
+ */
99
131
  fetchImpl?: typeof fetch;
100
132
  }
133
+ /** Lazily-loaded `node:http`/`node:https` modules. */
134
+ export interface NodeHttpModules {
135
+ http: typeof import('node:http');
136
+ https: typeof import('node:https');
137
+ }
138
+ /** A transport that issues one GET and returns the raw (unfollowed) response. */
139
+ type SsrfTransport = (url: string, request: {
140
+ headers?: Record<string, string>;
141
+ signal: AbortSignal;
142
+ pinned: ResolvedAddress[];
143
+ maxBytes?: number;
144
+ }) => Promise<Response>;
145
+ /** Select the Node transport module for a URL protocol. Exported for tests. */
146
+ export declare function pickHttpModule(protocol: string, modules: NodeHttpModules): NodeHttpModules['http'] | NodeHttpModules['https'];
147
+ /**
148
+ * A `node:net` lookup that always returns the pre-validated addresses, ignoring
149
+ * the queried hostname — pinning the socket to the exact IP {@link assertUrlSafe}
150
+ * validated so the connection cannot be re-resolved to a different (internal)
151
+ * address. Exported for tests.
152
+ */
153
+ export declare function makePinnedLookup(pinned: ResolvedAddress[]): (_hostname: string, options: unknown, callback?: unknown) => void;
154
+ /**
155
+ * Node transport that pins the connection to the validated address(es) via a
156
+ * custom `lookup`, preserving the original hostname for the `Host` header and
157
+ * TLS SNI. Manual redirects only (no `lookup` re-resolution between hops).
158
+ * Exported for tests.
159
+ */
160
+ export declare function nodePinnedTransport(modules: NodeHttpModules): SsrfTransport;
101
161
  /**
102
162
  * SSRF-safe `fetch`: validates the initial URL and **every redirect hop** with
103
- * {@link assertUrlSafe} before issuing the request, using manual redirect
104
- * handling so a 3xx to an internal target can't be followed without
105
- * re-validation. Returns the final {@link Response} (the caller checks
106
- * `response.ok` / reads the body).
163
+ * {@link assertUrlSafe} before issuing the request, then **pins** the connection
164
+ * to the validated IP (on Node) so the socket can't be rebound to an internal
165
+ * address. Uses manual redirect handling so a 3xx to an internal target can't be
166
+ * followed without re-validation. Returns the final {@link Response} (the caller
167
+ * checks `response.ok` / reads the body).
107
168
  */
108
169
  export declare function safeFetch(url: string, opts: SafeFetchOptions): Promise<Response>;
170
+ export {};