mcp-from-openapi 2.4.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/errors.d.ts CHANGED
@@ -11,6 +11,19 @@ export declare class OpenAPIToolError extends Error {
11
11
  export declare class LoadError extends OpenAPIToolError {
12
12
  constructor(message: string, context?: Record<string, any>);
13
13
  }
14
+ /**
15
+ * Error thrown when a spec URL or external `$ref` target is refused by the
16
+ * SSRF guard: it targets — or resolves to — a loopback / private / link-local /
17
+ * cloud-metadata address, uses a disallowed protocol, or is outside the
18
+ * configured allow-list.
19
+ *
20
+ * Subclasses {@link LoadError} so it propagates cleanly out of `fromURL` (whose
21
+ * catch re-throws `LoadError`) and so existing `instanceof LoadError` handling
22
+ * keeps working, while still being independently catchable.
23
+ */
24
+ export declare class SsrfError extends LoadError {
25
+ constructor(message: string, context?: Record<string, any>);
26
+ }
14
27
  /**
15
28
  * Error thrown when parsing an OpenAPI specification fails
16
29
  */
package/esm/index.mjs CHANGED
@@ -660,6 +660,11 @@ var LoadError = class extends OpenAPIToolError {
660
660
  super(message, context);
661
661
  }
662
662
  };
663
+ var SsrfError = class extends LoadError {
664
+ constructor(message, context) {
665
+ super(message, context);
666
+ }
667
+ };
663
668
  var ParseError = class extends OpenAPIToolError {
664
669
  constructor(message, context) {
665
670
  super(message, context);
@@ -792,6 +797,275 @@ function resolveSchemaFormats(schema, resolvers) {
792
797
  return result;
793
798
  }
794
799
 
800
+ // src/ssrf.ts
801
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
802
+ "localhost",
803
+ "localhost.localdomain",
804
+ "ip6-localhost",
805
+ "ip6-loopback",
806
+ "metadata",
807
+ "metadata.google.internal",
808
+ "metadata.goog"
809
+ ]);
810
+ function normalizeSsrfOptions(refResolution) {
811
+ return {
812
+ allowedHosts: refResolution?.allowedHosts ?? [],
813
+ blockedHosts: refResolution?.blockedHosts ?? [],
814
+ allowInternalIPs: refResolution?.allowInternalIPs ?? false
815
+ };
816
+ }
817
+ function decodeIpv4MappedIpv6(hostname) {
818
+ let h = hostname;
819
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
820
+ const lower = h.toLowerCase();
821
+ const marker = lower.lastIndexOf("::ffff:");
822
+ if (marker === -1) return null;
823
+ const tail = lower.slice(marker + "::ffff:".length);
824
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
825
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
826
+ if (hex) {
827
+ const hi = parseInt(hex[1], 16);
828
+ const lo = parseInt(hex[2], 16);
829
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
830
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
831
+ }
832
+ return null;
833
+ }
834
+ function parseIpv4(host) {
835
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
836
+ if (!m) return null;
837
+ const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
838
+ if (octets.some((n) => n > 255)) return null;
839
+ return octets;
840
+ }
841
+ function isBlockedIpv4(octets) {
842
+ const [a, b, c] = octets;
843
+ if (a === 0) return true;
844
+ if (a === 10) return true;
845
+ if (a === 127) return true;
846
+ if (a === 100 && b >= 64 && b <= 127) return true;
847
+ if (a === 169 && b === 254) return true;
848
+ if (a === 172 && b >= 16 && b <= 31) return true;
849
+ if (a === 192 && b === 0 && c === 0) return true;
850
+ if (a === 192 && b === 168) return true;
851
+ if (a === 198 && (b === 18 || b === 19)) return true;
852
+ if (a >= 224) return true;
853
+ return false;
854
+ }
855
+ function isBlockedIpv6(host) {
856
+ let h = host;
857
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
858
+ const zone = h.indexOf("%");
859
+ if (zone !== -1) h = h.slice(0, zone);
860
+ const lower = h.toLowerCase();
861
+ if (lower === "::" || lower === "::0") return true;
862
+ if (lower === "::1") return true;
863
+ if (/^f[cd]/.test(lower)) return true;
864
+ if (/^fe[89a-f]/.test(lower)) return true;
865
+ if (/^ff/.test(lower)) return true;
866
+ return false;
867
+ }
868
+ function isBlockedAddress(host) {
869
+ let h = host;
870
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
871
+ const mapped = decodeIpv4MappedIpv6(host);
872
+ if (mapped) {
873
+ const o = parseIpv4(mapped);
874
+ if (o) return isBlockedIpv4(o);
875
+ }
876
+ const v4 = parseIpv4(h);
877
+ if (v4) return isBlockedIpv4(v4);
878
+ if (h.includes(":")) return isBlockedIpv6(h);
879
+ return false;
880
+ }
881
+ function isIpLiteral(hostname) {
882
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
883
+ return parseIpv4(hostname) !== null;
884
+ }
885
+ function isBlockedHostname(hostname, ssrf) {
886
+ if (ssrf.allowInternalIPs) {
887
+ return ssrf.blockedHosts.includes(hostname);
888
+ }
889
+ if (ssrf.blockedHosts.includes(hostname)) return true;
890
+ const lower = hostname.toLowerCase();
891
+ const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
892
+ if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
893
+ return isBlockedAddress(hostname);
894
+ }
895
+ var SsrfResolverUnavailableError = class extends Error {
896
+ };
897
+ var defaultLookup = async (hostname) => {
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
+ }
904
+ return dns.promises.lookup(hostname, { all: true });
905
+ };
906
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
907
+ let parsed;
908
+ try {
909
+ parsed = new URL(url);
910
+ } catch {
911
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
912
+ }
913
+ const protocol = parsed.protocol.replace(/:$/, "");
914
+ if (protocol !== "http" && protocol !== "https") {
915
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
916
+ }
917
+ const hostname = parsed.hostname;
918
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
919
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
920
+ }
921
+ if (ssrf.allowInternalIPs) {
922
+ if (ssrf.blockedHosts.includes(hostname)) {
923
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
924
+ }
925
+ return [];
926
+ }
927
+ if (isBlockedHostname(hostname, ssrf)) {
928
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
929
+ }
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 [];
939
+ }
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 });
949
+ }
950
+ }
951
+ return addresses;
952
+ }
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
+ }
1037
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
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);
1044
+ let current = url;
1045
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1046
+ const pinned = await assertUrlSafe(current, ssrf, lookup);
1047
+ const controller = new AbortController();
1048
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1049
+ let response;
1050
+ try {
1051
+ response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
1052
+ } finally {
1053
+ clearTimeout(timer);
1054
+ }
1055
+ const status = typeof response.status === "number" ? response.status : 0;
1056
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
1057
+ if (!isRedirect || !followRedirects) {
1058
+ return response;
1059
+ }
1060
+ const location = response.headers?.get?.("location") ?? void 0;
1061
+ if (!location) {
1062
+ return response;
1063
+ }
1064
+ current = new URL(location, current).toString();
1065
+ }
1066
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
1067
+ }
1068
+
795
1069
  // src/generator.ts
796
1070
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
797
1071
  document;
@@ -817,14 +1091,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
817
1091
  */
818
1092
  static async fromURL(url, options = {}) {
819
1093
  try {
820
- const controller = new AbortController();
821
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
822
- const response = await fetch(url, {
1094
+ const response = await safeFetch(url, {
823
1095
  headers: options.headers,
824
- signal: controller.signal,
825
- redirect: options.followRedirects ?? true ? "follow" : "manual"
1096
+ timeoutMs: options.timeout ?? 3e4,
1097
+ followRedirects: options.followRedirects ?? true,
1098
+ ssrf: normalizeSsrfOptions(options.refResolution)
826
1099
  });
827
- clearTimeout(timeout);
828
1100
  if (!response.ok) {
829
1101
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
830
1102
  url,
@@ -915,87 +1187,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
915
1187
  const validator = new Validator();
916
1188
  return validator.validate(this.document);
917
1189
  }
918
- /**
919
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
920
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
921
- */
922
- static BLOCKED_HOSTNAME_PATTERNS = [
923
- "localhost",
924
- "metadata.google.internal",
925
- /^127\.\d+\.\d+\.\d+$/,
926
- // 127.0.0.0/8 loopback
927
- /^10\.\d+\.\d+\.\d+$/,
928
- // 10.0.0.0/8 private
929
- /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
930
- // 172.16.0.0/12 private
931
- /^192\.168\.\d+\.\d+$/,
932
- // 192.168.0.0/16 private
933
- /^169\.254\.\d+\.\d+$/,
934
- // 169.254.0.0/16 link-local / cloud metadata
935
- /^0\.0\.0\.0$/,
936
- // unspecified
937
- "::1",
938
- // IPv6 loopback
939
- /^fd[0-9a-f]{2}:/i,
940
- // fd00::/8 IPv6 ULA
941
- /^fe80:/i,
942
- // fe80::/10 IPv6 link-local
943
- /^\[::1\]$/,
944
- // bracketed IPv6 loopback
945
- /^\[fd[0-9a-f]{2}:/i,
946
- // bracketed IPv6 ULA
947
- /^\[fe80:/i
948
- // bracketed IPv6 link-local
949
- ];
950
- /**
951
- * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
952
- * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
953
- * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
954
- * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
955
- * dotted-quad blocklist patterns miss — letting an attacker reach a private /
956
- * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
957
- */
958
- static mappedIPv4FromIPv6(hostname) {
959
- let h = hostname;
960
- if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
961
- const lower = h.toLowerCase();
962
- const marker = lower.lastIndexOf("::ffff:");
963
- if (marker === -1) return null;
964
- const tail = lower.slice(marker + "::ffff:".length);
965
- if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
966
- const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
967
- if (hex) {
968
- const hi = parseInt(hex[1], 16);
969
- const lo = parseInt(hex[2], 16);
970
- if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
971
- return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
972
- }
973
- return null;
974
- }
975
- /**
976
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
977
- */
978
- isBlockedHost(hostname, refOpts) {
979
- if (refOpts.allowInternalIPs) {
980
- return refOpts.blockedHosts.includes(hostname);
981
- }
982
- if (refOpts.blockedHosts.includes(hostname)) {
983
- return true;
984
- }
985
- const candidates = [hostname];
986
- const mapped = _OpenAPIToolGenerator.mappedIPv4FromIPv6(hostname);
987
- if (mapped) candidates.push(mapped);
988
- for (const candidate of candidates) {
989
- for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
990
- if (typeof pattern === "string") {
991
- if (candidate === pattern) return true;
992
- } else {
993
- if (pattern.test(candidate)) return true;
994
- }
995
- }
996
- }
997
- return false;
998
- }
1190
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1191
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1192
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1193
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1194
+ // and per-hop redirect re-validation (`safeFetch`).
999
1195
  /**
1000
1196
  * Build $RefParser options based on refResolution configuration.
1001
1197
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -1022,13 +1218,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1022
1218
  const hostAllowSet = new Set(refOpts.allowedHosts);
1023
1219
  resolveConfig["http"] = {
1024
1220
  // SECURITY: never auto-follow HTTP redirects when resolving external
1025
- // `$ref`s. `canRead` (below) validates only the INITIAL URL; the
1026
- // resolver's default redirect-following (up to 5 hops) re-fetches the
1027
- // `Location` target WITHOUT re-invoking `canRead`, so an allowlisted host
1028
- // could 302 → `http://169.254.169.254/...` and smuggle a blocked target
1029
- // past the allowlist/blocklist. Setting `redirects: 0` refuses the first
1030
- // redirect; legitimate refs resolve in one hop.
1221
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1222
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1223
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1224
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1225
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1226
+ // our custom `read` (below) additionally refuses redirects itself.
1031
1227
  redirects: 0,
1228
+ // Synchronous gate: protocol, host allow-list, and literal/known
1229
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1230
+ // here (canRead cannot be async) and are caught in `read` via DNS
1231
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
1032
1232
  canRead: (file) => {
1033
1233
  try {
1034
1234
  const parsed = new URL(file.url);
@@ -1039,13 +1239,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1039
1239
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1040
1240
  return false;
1041
1241
  }
1042
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1242
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1043
1243
  return false;
1044
1244
  }
1045
1245
  return true;
1046
1246
  } catch {
1047
1247
  return false;
1048
1248
  }
1249
+ },
1250
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1251
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1252
+ // `this.options.headers` (the spec-load credentials) to third-party
1253
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1254
+ read: async (file) => {
1255
+ const response = await safeFetch(file.url, {
1256
+ timeoutMs: this.options.timeout,
1257
+ followRedirects: false,
1258
+ ssrf: refOpts
1259
+ });
1260
+ if (!response.ok) {
1261
+ throw new LoadError(
1262
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1263
+ { url: file.url, status: response.status }
1264
+ );
1265
+ }
1266
+ return response.text();
1049
1267
  }
1050
1268
  };
1051
1269
  } else {
@@ -1857,6 +2075,7 @@ function createSecurityContext(auth) {
1857
2075
  };
1858
2076
  }
1859
2077
  export {
2078
+ BLOCKED_HOSTNAMES,
1860
2079
  BUILTIN_FORMAT_RESOLVERS,
1861
2080
  GenerationError,
1862
2081
  LoadError,
@@ -1868,10 +2087,18 @@ export {
1868
2087
  SchemaBuilder,
1869
2088
  SchemaError,
1870
2089
  SecurityResolver,
2090
+ SsrfError,
1871
2091
  ValidationError,
1872
2092
  Validator,
2093
+ assertUrlSafe,
1873
2094
  createSecurityContext,
2095
+ decodeIpv4MappedIpv6,
2096
+ defaultLookup,
2097
+ isBlockedAddress,
2098
+ isBlockedHostname,
1874
2099
  isReferenceObject,
2100
+ normalizeSsrfOptions,
1875
2101
  resolveSchemaFormats,
2102
+ safeFetch,
1876
2103
  toJsonSchema
1877
2104
  };
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.4.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/generator.d.ts CHANGED
@@ -34,24 +34,6 @@ export declare class OpenAPIToolGenerator {
34
34
  * Validate the OpenAPI document
35
35
  */
36
36
  validate(): Promise<ValidationResult>;
37
- /**
38
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
39
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
40
- */
41
- private static readonly BLOCKED_HOSTNAME_PATTERNS;
42
- /**
43
- * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
44
- * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
45
- * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
46
- * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
47
- * dotted-quad blocklist patterns miss — letting an attacker reach a private /
48
- * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
49
- */
50
- private static mappedIPv4FromIPv6;
51
- /**
52
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
53
- */
54
- private isBlockedHost;
55
37
  /**
56
38
  * Build $RefParser options based on refResolution configuration.
57
39
  * Defaults: allow http/https, block file://, block internal IPs.
package/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export { ResponseBuilder } from './response-builder';
5
5
  export { Validator } from './validator';
6
6
  export { SecurityResolver, createSecurityContext } from './security-resolver';
7
7
  export { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver';
8
- export { OpenAPIToolError, LoadError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
8
+ export { OpenAPIToolError, LoadError, SsrfError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
9
+ export { assertUrlSafe, safeFetch, isBlockedAddress, isBlockedHostname, decodeIpv4MappedIpv6, normalizeSsrfOptions, defaultLookup, BLOCKED_HOSTNAMES, } from './ssrf';
10
+ export type { ResolvedSsrfOptions, ResolvedAddress, SsrfHostLookup, SafeFetchOptions } from './ssrf';
9
11
  export type { McpOpenAPITool, ParameterMapper, ToolMetadata, FrontMcpExtensionData, SerializationInfo, SecurityRequirement, SecurityParameterInfo, ServerInfo, RefResolutionOptions, LoadOptions, GenerateOptions, FormatResolver, JsonSchema, NamingStrategy, OperationWithContext, OpenAPIDocument, OpenAPIVersion, HTTPMethod, ParameterLocation, AuthType, OperationObject, ParameterObject, RequestBodyObject, ResponseObject, ResponsesObject, MediaTypeObject, HeaderObject, ExampleObject, PathItemObject, PathsObject, ServerObject, SecuritySchemeObject, ReferenceObject, TagObject, ExternalDocumentationObject, ServerVariableObject, EncodingObject, SecurityRequirementObject, SchemaObject, ValidationResult, ValidationErrorDetail, ValidationWarning, } from './types';
10
12
  export type { SecurityContext, ResolvedSecurity, DigestAuthCredentials, ClientCertificate, AWSCredentials, SignatureData, } from './security-resolver';
11
13
  export { isReferenceObject, toJsonSchema } from './types';
package/index.js CHANGED
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
+ BLOCKED_HOSTNAMES: () => BLOCKED_HOSTNAMES,
33
34
  BUILTIN_FORMAT_RESOLVERS: () => BUILTIN_FORMAT_RESOLVERS,
34
35
  GenerationError: () => GenerationError,
35
36
  LoadError: () => LoadError,
@@ -41,11 +42,19 @@ __export(index_exports, {
41
42
  SchemaBuilder: () => SchemaBuilder,
42
43
  SchemaError: () => SchemaError,
43
44
  SecurityResolver: () => SecurityResolver,
45
+ SsrfError: () => SsrfError,
44
46
  ValidationError: () => ValidationError,
45
47
  Validator: () => Validator,
48
+ assertUrlSafe: () => assertUrlSafe,
46
49
  createSecurityContext: () => createSecurityContext,
50
+ decodeIpv4MappedIpv6: () => decodeIpv4MappedIpv6,
51
+ defaultLookup: () => defaultLookup,
52
+ isBlockedAddress: () => isBlockedAddress,
53
+ isBlockedHostname: () => isBlockedHostname,
47
54
  isReferenceObject: () => isReferenceObject,
55
+ normalizeSsrfOptions: () => normalizeSsrfOptions,
48
56
  resolveSchemaFormats: () => resolveSchemaFormats,
57
+ safeFetch: () => safeFetch,
49
58
  toJsonSchema: () => toJsonSchema
50
59
  });
51
60
  module.exports = __toCommonJS(index_exports);
@@ -712,6 +721,11 @@ var LoadError = class extends OpenAPIToolError {
712
721
  super(message, context);
713
722
  }
714
723
  };
724
+ var SsrfError = class extends LoadError {
725
+ constructor(message, context) {
726
+ super(message, context);
727
+ }
728
+ };
715
729
  var ParseError = class extends OpenAPIToolError {
716
730
  constructor(message, context) {
717
731
  super(message, context);
@@ -844,6 +858,275 @@ function resolveSchemaFormats(schema, resolvers) {
844
858
  return result;
845
859
  }
846
860
 
861
+ // src/ssrf.ts
862
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
863
+ "localhost",
864
+ "localhost.localdomain",
865
+ "ip6-localhost",
866
+ "ip6-loopback",
867
+ "metadata",
868
+ "metadata.google.internal",
869
+ "metadata.goog"
870
+ ]);
871
+ function normalizeSsrfOptions(refResolution) {
872
+ return {
873
+ allowedHosts: refResolution?.allowedHosts ?? [],
874
+ blockedHosts: refResolution?.blockedHosts ?? [],
875
+ allowInternalIPs: refResolution?.allowInternalIPs ?? false
876
+ };
877
+ }
878
+ function decodeIpv4MappedIpv6(hostname) {
879
+ let h = hostname;
880
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
881
+ const lower = h.toLowerCase();
882
+ const marker = lower.lastIndexOf("::ffff:");
883
+ if (marker === -1) return null;
884
+ const tail = lower.slice(marker + "::ffff:".length);
885
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
886
+ const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
887
+ if (hex) {
888
+ const hi = parseInt(hex[1], 16);
889
+ const lo = parseInt(hex[2], 16);
890
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
891
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
892
+ }
893
+ return null;
894
+ }
895
+ function parseIpv4(host) {
896
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
897
+ if (!m) return null;
898
+ const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
899
+ if (octets.some((n) => n > 255)) return null;
900
+ return octets;
901
+ }
902
+ function isBlockedIpv4(octets) {
903
+ const [a, b, c] = octets;
904
+ if (a === 0) return true;
905
+ if (a === 10) return true;
906
+ if (a === 127) return true;
907
+ if (a === 100 && b >= 64 && b <= 127) return true;
908
+ if (a === 169 && b === 254) return true;
909
+ if (a === 172 && b >= 16 && b <= 31) return true;
910
+ if (a === 192 && b === 0 && c === 0) return true;
911
+ if (a === 192 && b === 168) return true;
912
+ if (a === 198 && (b === 18 || b === 19)) return true;
913
+ if (a >= 224) return true;
914
+ return false;
915
+ }
916
+ function isBlockedIpv6(host) {
917
+ let h = host;
918
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
919
+ const zone = h.indexOf("%");
920
+ if (zone !== -1) h = h.slice(0, zone);
921
+ const lower = h.toLowerCase();
922
+ if (lower === "::" || lower === "::0") return true;
923
+ if (lower === "::1") return true;
924
+ if (/^f[cd]/.test(lower)) return true;
925
+ if (/^fe[89a-f]/.test(lower)) return true;
926
+ if (/^ff/.test(lower)) return true;
927
+ return false;
928
+ }
929
+ function isBlockedAddress(host) {
930
+ let h = host;
931
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
932
+ const mapped = decodeIpv4MappedIpv6(host);
933
+ if (mapped) {
934
+ const o = parseIpv4(mapped);
935
+ if (o) return isBlockedIpv4(o);
936
+ }
937
+ const v4 = parseIpv4(h);
938
+ if (v4) return isBlockedIpv4(v4);
939
+ if (h.includes(":")) return isBlockedIpv6(h);
940
+ return false;
941
+ }
942
+ function isIpLiteral(hostname) {
943
+ if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
944
+ return parseIpv4(hostname) !== null;
945
+ }
946
+ function isBlockedHostname(hostname, ssrf) {
947
+ if (ssrf.allowInternalIPs) {
948
+ return ssrf.blockedHosts.includes(hostname);
949
+ }
950
+ if (ssrf.blockedHosts.includes(hostname)) return true;
951
+ const lower = hostname.toLowerCase();
952
+ const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
953
+ if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
954
+ return isBlockedAddress(hostname);
955
+ }
956
+ var SsrfResolverUnavailableError = class extends Error {
957
+ };
958
+ var defaultLookup = async (hostname) => {
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
+ }
965
+ return dns.promises.lookup(hostname, { all: true });
966
+ };
967
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
968
+ let parsed;
969
+ try {
970
+ parsed = new URL(url);
971
+ } catch {
972
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
973
+ }
974
+ const protocol = parsed.protocol.replace(/:$/, "");
975
+ if (protocol !== "http" && protocol !== "https") {
976
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
977
+ }
978
+ const hostname = parsed.hostname;
979
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
980
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
981
+ }
982
+ if (ssrf.allowInternalIPs) {
983
+ if (ssrf.blockedHosts.includes(hostname)) {
984
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
985
+ }
986
+ return [];
987
+ }
988
+ if (isBlockedHostname(hostname, ssrf)) {
989
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
990
+ }
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 [];
1000
+ }
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 });
1010
+ }
1011
+ }
1012
+ return addresses;
1013
+ }
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
+ }
1098
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
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);
1105
+ let current = url;
1106
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1107
+ const pinned = await assertUrlSafe(current, ssrf, lookup);
1108
+ const controller = new AbortController();
1109
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1110
+ let response;
1111
+ try {
1112
+ response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
1113
+ } finally {
1114
+ clearTimeout(timer);
1115
+ }
1116
+ const status = typeof response.status === "number" ? response.status : 0;
1117
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
1118
+ if (!isRedirect || !followRedirects) {
1119
+ return response;
1120
+ }
1121
+ const location = response.headers?.get?.("location") ?? void 0;
1122
+ if (!location) {
1123
+ return response;
1124
+ }
1125
+ current = new URL(location, current).toString();
1126
+ }
1127
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
1128
+ }
1129
+
847
1130
  // src/generator.ts
848
1131
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
849
1132
  document;
@@ -869,14 +1152,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
869
1152
  */
870
1153
  static async fromURL(url, options = {}) {
871
1154
  try {
872
- const controller = new AbortController();
873
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
874
- const response = await fetch(url, {
1155
+ const response = await safeFetch(url, {
875
1156
  headers: options.headers,
876
- signal: controller.signal,
877
- redirect: options.followRedirects ?? true ? "follow" : "manual"
1157
+ timeoutMs: options.timeout ?? 3e4,
1158
+ followRedirects: options.followRedirects ?? true,
1159
+ ssrf: normalizeSsrfOptions(options.refResolution)
878
1160
  });
879
- clearTimeout(timeout);
880
1161
  if (!response.ok) {
881
1162
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
882
1163
  url,
@@ -967,87 +1248,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
967
1248
  const validator = new Validator();
968
1249
  return validator.validate(this.document);
969
1250
  }
970
- /**
971
- * Hostnames and IP patterns that are blocked by default to prevent SSRF.
972
- * Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
973
- */
974
- static BLOCKED_HOSTNAME_PATTERNS = [
975
- "localhost",
976
- "metadata.google.internal",
977
- /^127\.\d+\.\d+\.\d+$/,
978
- // 127.0.0.0/8 loopback
979
- /^10\.\d+\.\d+\.\d+$/,
980
- // 10.0.0.0/8 private
981
- /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/,
982
- // 172.16.0.0/12 private
983
- /^192\.168\.\d+\.\d+$/,
984
- // 192.168.0.0/16 private
985
- /^169\.254\.\d+\.\d+$/,
986
- // 169.254.0.0/16 link-local / cloud metadata
987
- /^0\.0\.0\.0$/,
988
- // unspecified
989
- "::1",
990
- // IPv6 loopback
991
- /^fd[0-9a-f]{2}:/i,
992
- // fd00::/8 IPv6 ULA
993
- /^fe80:/i,
994
- // fe80::/10 IPv6 link-local
995
- /^\[::1\]$/,
996
- // bracketed IPv6 loopback
997
- /^\[fd[0-9a-f]{2}:/i,
998
- // bracketed IPv6 ULA
999
- /^\[fe80:/i
1000
- // bracketed IPv6 link-local
1001
- ];
1002
- /**
1003
- * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
1004
- * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
1005
- * or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
1006
- * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
1007
- * dotted-quad blocklist patterns miss — letting an attacker reach a private /
1008
- * metadata IPv4 through the v6 mapping. We re-check the decoded v4.
1009
- */
1010
- static mappedIPv4FromIPv6(hostname) {
1011
- let h = hostname;
1012
- if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
1013
- const lower = h.toLowerCase();
1014
- const marker = lower.lastIndexOf("::ffff:");
1015
- if (marker === -1) return null;
1016
- const tail = lower.slice(marker + "::ffff:".length);
1017
- if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
1018
- const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
1019
- if (hex) {
1020
- const hi = parseInt(hex[1], 16);
1021
- const lo = parseInt(hex[2], 16);
1022
- if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
1023
- return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1024
- }
1025
- return null;
1026
- }
1027
- /**
1028
- * Check whether a hostname is blocked (internal/private IP or explicit blocklist).
1029
- */
1030
- isBlockedHost(hostname, refOpts) {
1031
- if (refOpts.allowInternalIPs) {
1032
- return refOpts.blockedHosts.includes(hostname);
1033
- }
1034
- if (refOpts.blockedHosts.includes(hostname)) {
1035
- return true;
1036
- }
1037
- const candidates = [hostname];
1038
- const mapped = _OpenAPIToolGenerator.mappedIPv4FromIPv6(hostname);
1039
- if (mapped) candidates.push(mapped);
1040
- for (const candidate of candidates) {
1041
- for (const pattern of _OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
1042
- if (typeof pattern === "string") {
1043
- if (candidate === pattern) return true;
1044
- } else {
1045
- if (pattern.test(candidate)) return true;
1046
- }
1047
- }
1048
- }
1049
- return false;
1050
- }
1251
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1252
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1253
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1254
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1255
+ // and per-hop redirect re-validation (`safeFetch`).
1051
1256
  /**
1052
1257
  * Build $RefParser options based on refResolution configuration.
1053
1258
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -1074,13 +1279,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1074
1279
  const hostAllowSet = new Set(refOpts.allowedHosts);
1075
1280
  resolveConfig["http"] = {
1076
1281
  // SECURITY: never auto-follow HTTP redirects when resolving external
1077
- // `$ref`s. `canRead` (below) validates only the INITIAL URL; the
1078
- // resolver's default redirect-following (up to 5 hops) re-fetches the
1079
- // `Location` target WITHOUT re-invoking `canRead`, so an allowlisted host
1080
- // could 302 → `http://169.254.169.254/...` and smuggle a blocked target
1081
- // past the allowlist/blocklist. Setting `redirects: 0` refuses the first
1082
- // redirect; legitimate refs resolve in one hop.
1282
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1283
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1284
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1285
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1286
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1287
+ // our custom `read` (below) additionally refuses redirects itself.
1083
1288
  redirects: 0,
1289
+ // Synchronous gate: protocol, host allow-list, and literal/known
1290
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1291
+ // here (canRead cannot be async) and are caught in `read` via DNS
1292
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
1084
1293
  canRead: (file) => {
1085
1294
  try {
1086
1295
  const parsed = new URL(file.url);
@@ -1091,13 +1300,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1091
1300
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1092
1301
  return false;
1093
1302
  }
1094
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1303
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1095
1304
  return false;
1096
1305
  }
1097
1306
  return true;
1098
1307
  } catch {
1099
1308
  return false;
1100
1309
  }
1310
+ },
1311
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1312
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1313
+ // `this.options.headers` (the spec-load credentials) to third-party
1314
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1315
+ read: async (file) => {
1316
+ const response = await safeFetch(file.url, {
1317
+ timeoutMs: this.options.timeout,
1318
+ followRedirects: false,
1319
+ ssrf: refOpts
1320
+ });
1321
+ if (!response.ok) {
1322
+ throw new LoadError(
1323
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1324
+ { url: file.url, status: response.status }
1325
+ );
1326
+ }
1327
+ return response.text();
1101
1328
  }
1102
1329
  };
1103
1330
  } else {
@@ -1910,6 +2137,7 @@ function createSecurityContext(auth) {
1910
2137
  }
1911
2138
  // Annotate the CommonJS export names for ESM import in node:
1912
2139
  0 && (module.exports = {
2140
+ BLOCKED_HOSTNAMES,
1913
2141
  BUILTIN_FORMAT_RESOLVERS,
1914
2142
  GenerationError,
1915
2143
  LoadError,
@@ -1921,10 +2149,18 @@ function createSecurityContext(auth) {
1921
2149
  SchemaBuilder,
1922
2150
  SchemaError,
1923
2151
  SecurityResolver,
2152
+ SsrfError,
1924
2153
  ValidationError,
1925
2154
  Validator,
2155
+ assertUrlSafe,
1926
2156
  createSecurityContext,
2157
+ decodeIpv4MappedIpv6,
2158
+ defaultLookup,
2159
+ isBlockedAddress,
2160
+ isBlockedHostname,
1927
2161
  isReferenceObject,
2162
+ normalizeSsrfOptions,
1928
2163
  resolveSchemaFormats,
2164
+ safeFetch,
1929
2165
  toJsonSchema
1930
2166
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-from-openapi",
3
- "version": "2.4.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 ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * SSRF protection for spec loading and external `$ref` resolution.
3
+ *
4
+ * The generator fetches two kinds of attacker-influenceable URLs:
5
+ * 1. the OpenAPI spec itself (`fromURL`), and
6
+ * 2. external `$ref` targets during dereferencing.
7
+ *
8
+ * A hostname-string denylist (the pre-2.5 approach) is bypassable, as reported
9
+ * in GHSA-65h7-9wrw-629c:
10
+ * - DNS names that resolve to internal IPs, e.g. `http://127.0.0.1.nip.io/`
11
+ * — the literal-string `127.0.0.1` patterns never match, yet the name
12
+ * resolves to loopback;
13
+ * - IPv4-mapped IPv6 forms (handled since 2.4 via {@link decodeIpv4MappedIpv6});
14
+ * - redirects from an allowed host to an internal one.
15
+ *
16
+ * This module validates the **resolved IP**, not just the hostname string:
17
+ * - parses/normalizes IPv4 (incl. numeric/hex/octal forms canonicalized by
18
+ * `new URL()`) and IPv4-mapped IPv6 before range checks;
19
+ * - blocks loopback / private (RFC 1918) / CGNAT (RFC 6598) / link-local /
20
+ * multicast / unspecified / reserved ranges and cloud-metadata endpoints;
21
+ * - resolves the hostname (Node, via `node:dns`) and rejects if **any**
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;
30
+ * - re-validates every redirect hop ({@link safeFetch}) instead of letting the
31
+ * HTTP client follow 3xx blindly.
32
+ *
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.
38
+ *
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}).
42
+ */
43
+ import type { RefResolutionOptions } from './types';
44
+ /** The subset of {@link RefResolutionOptions} relevant to address blocking. */
45
+ export interface ResolvedSsrfOptions {
46
+ allowedHosts: string[];
47
+ blockedHosts: string[];
48
+ allowInternalIPs: boolean;
49
+ }
50
+ /** Resolved DNS address shape (subset of Node's `dns.LookupAddress`). */
51
+ export interface ResolvedAddress {
52
+ address: string;
53
+ family: number;
54
+ }
55
+ /** Hostname → resolved addresses. Injectable for testing. */
56
+ export type SsrfHostLookup = (hostname: string) => Promise<ResolvedAddress[]>;
57
+ /**
58
+ * Non-IP hostnames that map to internal targets, so the IP-range checks alone
59
+ * would miss them when DNS resolution is unavailable. DNS resolution (when
60
+ * available) also catches these; this set is defense-in-depth.
61
+ */
62
+ export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
63
+ /** Normalize a (possibly undefined) {@link RefResolutionOptions} to the SSRF subset. */
64
+ export declare function normalizeSsrfOptions(refResolution?: RefResolutionOptions): ResolvedSsrfOptions;
65
+ /**
66
+ * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
67
+ * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4, or
68
+ * `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
69
+ * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
70
+ * dotted-quad range checks would otherwise miss.
71
+ */
72
+ export declare function decodeIpv4MappedIpv6(hostname: string): string | null;
73
+ /**
74
+ * Predicate: is `host` (an IP literal — dotted-quad IPv4, bracketed/zoned IPv6,
75
+ * or IPv4-mapped IPv6) in a blocked, non-public range? Returns `false` for
76
+ * non-IP-literal hostnames (use DNS resolution for those).
77
+ */
78
+ export declare function isBlockedAddress(host: string): boolean;
79
+ /**
80
+ * Synchronous host check used by the `$RefParser` `canRead` filter (which cannot
81
+ * be async). Blocks known internal hostnames, explicit `blockedHosts`, and
82
+ * literal internal IPs (incl. IPv4-mapped IPv6). DNS names that *resolve* to
83
+ * internal addresses are caught later, asynchronously, in {@link safeFetch}.
84
+ */
85
+ export declare function isBlockedHostname(hostname: string, ssrf: ResolvedSsrfOptions): boolean;
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. */
95
+ export declare const defaultLookup: SsrfHostLookup;
96
+ /**
97
+ * Validate that `url` is safe to fetch (spec URL or `$ref` target), throwing
98
+ * {@link SsrfError} if not. Enforces http/https, the `allowedHosts` allow-list,
99
+ * the internal-address denylist, and — for DNS names — resolves and rejects if
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 `[]`.
111
+ */
112
+ export declare function assertUrlSafe(url: string, ssrf: ResolvedSsrfOptions, lookup?: SsrfHostLookup): Promise<ResolvedAddress[]>;
113
+ /** Options for {@link safeFetch}. */
114
+ export interface SafeFetchOptions {
115
+ headers?: Record<string, string>;
116
+ timeoutMs?: number;
117
+ /** Follow 3xx redirects (re-validating each hop). @default true */
118
+ followRedirects?: boolean;
119
+ /** Max redirect hops before failing. @default 5 */
120
+ maxRedirects?: number;
121
+ /** Max response body bytes before the request is aborted (Node transport). @default 10 MiB */
122
+ maxResponseBytes?: number;
123
+ ssrf: ResolvedSsrfOptions;
124
+ /** Injectable DNS resolver (tests). */
125
+ lookup?: SsrfHostLookup;
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
+ */
131
+ fetchImpl?: typeof fetch;
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;
161
+ /**
162
+ * SSRF-safe `fetch`: validates the initial URL and **every redirect hop** with
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).
168
+ */
169
+ export declare function safeFetch(url: string, opts: SafeFetchOptions): Promise<Response>;
170
+ export {};
package/types.d.ts CHANGED
@@ -327,9 +327,15 @@ export interface ServerInfo {
327
327
  variables?: Record<string, ServerVariableObject>;
328
328
  }
329
329
  /**
330
- * Controls how external $ref pointers are resolved during dereferencing.
331
- * By default, only http/https protocols are allowed and internal/private
332
- * IP addresses are blocked to prevent SSRF attacks.
330
+ * Controls how external `$ref` pointers are resolved during dereferencing, and
331
+ * the host policy applied to the initial spec-URL fetch in `fromURL`.
332
+ *
333
+ * By default only http/https protocols are allowed and internal/private targets
334
+ * are blocked to prevent SSRF. As of 2.5.0 the guard validates the **resolved
335
+ * IP** (it resolves DNS and rejects hostnames that map to internal addresses —
336
+ * e.g. `127.0.0.1.nip.io`), normalizes IPv4-mapped IPv6, and re-validates every
337
+ * HTTP redirect hop. `allowedHosts` / `blockedHosts` / `allowInternalIPs` apply
338
+ * to both the spec URL and external `$ref`s.
333
339
  */
334
340
  export interface RefResolutionOptions {
335
341
  /**
@@ -386,13 +392,17 @@ export interface LoadOptions {
386
392
  */
387
393
  validate?: boolean;
388
394
  /**
389
- * Whether to follow HTTP redirects
395
+ * Whether to follow HTTP redirects when fetching the spec URL. Each redirect
396
+ * hop is re-validated against the SSRF guard before being followed (a 3xx to
397
+ * an internal target is refused), so following is safe by default.
390
398
  * @default true
391
399
  */
392
400
  followRedirects?: boolean;
393
401
  /**
394
- * Controls external $ref resolution security.
395
- * By default, file:// is blocked and internal IPs are blocked.
402
+ * Controls spec-loading security: external `$ref` resolution AND the host
403
+ * policy for the initial spec-URL fetch. By default `file://` is blocked,
404
+ * internal/private targets are blocked, and hostnames are DNS-resolved and
405
+ * re-checked against the internal-address ranges.
396
406
  * @see RefResolutionOptions
397
407
  */
398
408
  refResolution?: RefResolutionOptions;