mcp-from-openapi 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,178 @@ 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 defaultLookup = async (hostname) => {
896
+ const dns = await import("node:dns");
897
+ return dns.promises.lookup(hostname, { all: true });
898
+ };
899
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
900
+ let parsed;
901
+ try {
902
+ parsed = new URL(url);
903
+ } catch {
904
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
905
+ }
906
+ const protocol = parsed.protocol.replace(/:$/, "");
907
+ if (protocol !== "http" && protocol !== "https") {
908
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
909
+ }
910
+ const hostname = parsed.hostname;
911
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
912
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
913
+ }
914
+ if (ssrf.allowInternalIPs) {
915
+ if (ssrf.blockedHosts.includes(hostname)) {
916
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
917
+ }
918
+ return;
919
+ }
920
+ if (isBlockedHostname(hostname, ssrf)) {
921
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
922
+ }
923
+ if (!isIpLiteral(hostname)) {
924
+ let addresses;
925
+ try {
926
+ addresses = await lookup(hostname);
927
+ } catch {
928
+ return;
929
+ }
930
+ for (const { address } of addresses) {
931
+ if (isBlockedAddress(address)) {
932
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
933
+ }
934
+ }
935
+ }
936
+ }
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") {
941
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
942
+ }
943
+ let current = url;
944
+ for (let hop = 0; hop <= maxRedirects; hop++) {
945
+ await assertUrlSafe(current, ssrf, lookup);
946
+ const controller = new AbortController();
947
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
948
+ let response;
949
+ try {
950
+ response = await fetchImpl(current, {
951
+ headers,
952
+ signal: controller.signal,
953
+ redirect: "manual"
954
+ });
955
+ } finally {
956
+ clearTimeout(timer);
957
+ }
958
+ const status = typeof response.status === "number" ? response.status : 0;
959
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
960
+ if (!isRedirect || !followRedirects) {
961
+ return response;
962
+ }
963
+ const location = response.headers?.get?.("location") ?? void 0;
964
+ if (!location) {
965
+ return response;
966
+ }
967
+ current = new URL(location, current).toString();
968
+ }
969
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
970
+ }
971
+
795
972
  // src/generator.ts
796
973
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
797
974
  document;
@@ -817,14 +994,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
817
994
  */
818
995
  static async fromURL(url, options = {}) {
819
996
  try {
820
- const controller = new AbortController();
821
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
822
- const response = await fetch(url, {
997
+ const response = await safeFetch(url, {
823
998
  headers: options.headers,
824
- signal: controller.signal,
825
- redirect: options.followRedirects ?? true ? "follow" : "manual"
999
+ timeoutMs: options.timeout ?? 3e4,
1000
+ followRedirects: options.followRedirects ?? true,
1001
+ ssrf: normalizeSsrfOptions(options.refResolution)
826
1002
  });
827
- clearTimeout(timeout);
828
1003
  if (!response.ok) {
829
1004
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
830
1005
  url,
@@ -915,87 +1090,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
915
1090
  const validator = new Validator();
916
1091
  return validator.validate(this.document);
917
1092
  }
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
- }
1093
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1094
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1095
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1096
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1097
+ // and per-hop redirect re-validation (`safeFetch`).
999
1098
  /**
1000
1099
  * Build $RefParser options based on refResolution configuration.
1001
1100
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -1022,13 +1121,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1022
1121
  const hostAllowSet = new Set(refOpts.allowedHosts);
1023
1122
  resolveConfig["http"] = {
1024
1123
  // 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.
1124
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1125
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1126
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1127
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1128
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1129
+ // our custom `read` (below) additionally refuses redirects itself.
1031
1130
  redirects: 0,
1131
+ // Synchronous gate: protocol, host allow-list, and literal/known
1132
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1133
+ // here (canRead cannot be async) and are caught in `read` via DNS
1134
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
1032
1135
  canRead: (file) => {
1033
1136
  try {
1034
1137
  const parsed = new URL(file.url);
@@ -1039,13 +1142,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1039
1142
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1040
1143
  return false;
1041
1144
  }
1042
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1145
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1043
1146
  return false;
1044
1147
  }
1045
1148
  return true;
1046
1149
  } catch {
1047
1150
  return false;
1048
1151
  }
1152
+ },
1153
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1154
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1155
+ // `this.options.headers` (the spec-load credentials) to third-party
1156
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1157
+ read: async (file) => {
1158
+ const response = await safeFetch(file.url, {
1159
+ timeoutMs: this.options.timeout,
1160
+ followRedirects: false,
1161
+ ssrf: refOpts
1162
+ });
1163
+ if (!response.ok) {
1164
+ throw new LoadError(
1165
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1166
+ { url: file.url, status: response.status }
1167
+ );
1168
+ }
1169
+ return response.text();
1049
1170
  }
1050
1171
  };
1051
1172
  } else {
@@ -1857,6 +1978,7 @@ function createSecurityContext(auth) {
1857
1978
  };
1858
1979
  }
1859
1980
  export {
1981
+ BLOCKED_HOSTNAMES,
1860
1982
  BUILTIN_FORMAT_RESOLVERS,
1861
1983
  GenerationError,
1862
1984
  LoadError,
@@ -1868,10 +1990,18 @@ export {
1868
1990
  SchemaBuilder,
1869
1991
  SchemaError,
1870
1992
  SecurityResolver,
1993
+ SsrfError,
1871
1994
  ValidationError,
1872
1995
  Validator,
1996
+ assertUrlSafe,
1873
1997
  createSecurityContext,
1998
+ decodeIpv4MappedIpv6,
1999
+ defaultLookup,
2000
+ isBlockedAddress,
2001
+ isBlockedHostname,
1874
2002
  isReferenceObject,
2003
+ normalizeSsrfOptions,
1875
2004
  resolveSchemaFormats,
2005
+ safeFetch,
1876
2006
  toJsonSchema
1877
2007
  };
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.0",
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,178 @@ 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 defaultLookup = async (hostname) => {
957
+ const dns = await import("node:dns");
958
+ return dns.promises.lookup(hostname, { all: true });
959
+ };
960
+ async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
961
+ let parsed;
962
+ try {
963
+ parsed = new URL(url);
964
+ } catch {
965
+ throw new SsrfError(`Invalid spec URL: ${url}`, { url });
966
+ }
967
+ const protocol = parsed.protocol.replace(/:$/, "");
968
+ if (protocol !== "http" && protocol !== "https") {
969
+ throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
970
+ }
971
+ const hostname = parsed.hostname;
972
+ if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
973
+ throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
974
+ }
975
+ if (ssrf.allowInternalIPs) {
976
+ if (ssrf.blockedHosts.includes(hostname)) {
977
+ throw new SsrfError(`Host "${hostname}" is blocked`, { url });
978
+ }
979
+ return;
980
+ }
981
+ if (isBlockedHostname(hostname, ssrf)) {
982
+ throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
983
+ }
984
+ if (!isIpLiteral(hostname)) {
985
+ let addresses;
986
+ try {
987
+ addresses = await lookup(hostname);
988
+ } catch {
989
+ return;
990
+ }
991
+ for (const { address } of addresses) {
992
+ if (isBlockedAddress(address)) {
993
+ throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
994
+ }
995
+ }
996
+ }
997
+ }
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") {
1002
+ throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
1003
+ }
1004
+ let current = url;
1005
+ for (let hop = 0; hop <= maxRedirects; hop++) {
1006
+ await assertUrlSafe(current, ssrf, lookup);
1007
+ const controller = new AbortController();
1008
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1009
+ let response;
1010
+ try {
1011
+ response = await fetchImpl(current, {
1012
+ headers,
1013
+ signal: controller.signal,
1014
+ redirect: "manual"
1015
+ });
1016
+ } finally {
1017
+ clearTimeout(timer);
1018
+ }
1019
+ const status = typeof response.status === "number" ? response.status : 0;
1020
+ const isRedirect = status >= 300 && status < 400 && status !== 304;
1021
+ if (!isRedirect || !followRedirects) {
1022
+ return response;
1023
+ }
1024
+ const location = response.headers?.get?.("location") ?? void 0;
1025
+ if (!location) {
1026
+ return response;
1027
+ }
1028
+ current = new URL(location, current).toString();
1029
+ }
1030
+ throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
1031
+ }
1032
+
847
1033
  // src/generator.ts
848
1034
  var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
849
1035
  document;
@@ -869,14 +1055,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
869
1055
  */
870
1056
  static async fromURL(url, options = {}) {
871
1057
  try {
872
- const controller = new AbortController();
873
- const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
874
- const response = await fetch(url, {
1058
+ const response = await safeFetch(url, {
875
1059
  headers: options.headers,
876
- signal: controller.signal,
877
- redirect: options.followRedirects ?? true ? "follow" : "manual"
1060
+ timeoutMs: options.timeout ?? 3e4,
1061
+ followRedirects: options.followRedirects ?? true,
1062
+ ssrf: normalizeSsrfOptions(options.refResolution)
878
1063
  });
879
- clearTimeout(timeout);
880
1064
  if (!response.ok) {
881
1065
  throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
882
1066
  url,
@@ -967,87 +1151,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
967
1151
  const validator = new Validator();
968
1152
  return validator.validate(this.document);
969
1153
  }
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
- }
1154
+ // NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
1155
+ // in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
1156
+ // shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
1157
+ // augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
1158
+ // and per-hop redirect re-validation (`safeFetch`).
1051
1159
  /**
1052
1160
  * Build $RefParser options based on refResolution configuration.
1053
1161
  * Defaults: allow http/https, block file://, block internal IPs.
@@ -1074,13 +1182,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1074
1182
  const hostAllowSet = new Set(refOpts.allowedHosts);
1075
1183
  resolveConfig["http"] = {
1076
1184
  // 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.
1185
+ // `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
1186
+ // default redirect-following (up to 5 hops) re-fetches the `Location`
1187
+ // target WITHOUT re-invoking `canRead`, so an allowlisted host could
1188
+ // 302 → `http://169.254.169.254/...` and smuggle a blocked target past
1189
+ // the allow/deny lists. `redirects: 0` refuses the first redirect, and
1190
+ // our custom `read` (below) additionally refuses redirects itself.
1083
1191
  redirects: 0,
1192
+ // Synchronous gate: protocol, host allow-list, and literal/known
1193
+ // internal hosts. DNS names that *resolve* to internal addresses pass
1194
+ // here (canRead cannot be async) and are caught in `read` via DNS
1195
+ // resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
1084
1196
  canRead: (file) => {
1085
1197
  try {
1086
1198
  const parsed = new URL(file.url);
@@ -1091,13 +1203,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
1091
1203
  if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
1092
1204
  return false;
1093
1205
  }
1094
- if (this.isBlockedHost(parsed.hostname, refOpts)) {
1206
+ if (isBlockedHostname(parsed.hostname, refOpts)) {
1095
1207
  return false;
1096
1208
  }
1097
1209
  return true;
1098
1210
  } catch {
1099
1211
  return false;
1100
1212
  }
1213
+ },
1214
+ // SSRF-safe fetch: resolves DNS and rejects names that map to internal
1215
+ // addresses, and refuses redirects. NOTE: deliberately does NOT forward
1216
+ // `this.options.headers` (the spec-load credentials) to third-party
1217
+ // `$ref` hosts — that would leak the spec's auth token cross-origin.
1218
+ read: async (file) => {
1219
+ const response = await safeFetch(file.url, {
1220
+ timeoutMs: this.options.timeout,
1221
+ followRedirects: false,
1222
+ ssrf: refOpts
1223
+ });
1224
+ if (!response.ok) {
1225
+ throw new LoadError(
1226
+ `Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
1227
+ { url: file.url, status: response.status }
1228
+ );
1229
+ }
1230
+ return response.text();
1101
1231
  }
1102
1232
  };
1103
1233
  } else {
@@ -1910,6 +2040,7 @@ function createSecurityContext(auth) {
1910
2040
  }
1911
2041
  // Annotate the CommonJS export names for ESM import in node:
1912
2042
  0 && (module.exports = {
2043
+ BLOCKED_HOSTNAMES,
1913
2044
  BUILTIN_FORMAT_RESOLVERS,
1914
2045
  GenerationError,
1915
2046
  LoadError,
@@ -1921,10 +2052,18 @@ function createSecurityContext(auth) {
1921
2052
  SchemaBuilder,
1922
2053
  SchemaError,
1923
2054
  SecurityResolver,
2055
+ SsrfError,
1924
2056
  ValidationError,
1925
2057
  Validator,
2058
+ assertUrlSafe,
1926
2059
  createSecurityContext,
2060
+ decodeIpv4MappedIpv6,
2061
+ defaultLookup,
2062
+ isBlockedAddress,
2063
+ isBlockedHostname,
1927
2064
  isReferenceObject,
2065
+ normalizeSsrfOptions,
1928
2066
  resolveSchemaFormats,
2067
+ safeFetch,
1929
2068
  toJsonSchema
1930
2069
  });
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.0",
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,108 @@
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
+ * - re-validates every redirect hop ({@link safeFetch}) instead of letting the
24
+ * HTTP client follow 3xx blindly.
25
+ *
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.
28
+ *
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.
34
+ */
35
+ import type { RefResolutionOptions } from './types';
36
+ /** The subset of {@link RefResolutionOptions} relevant to address blocking. */
37
+ export interface ResolvedSsrfOptions {
38
+ allowedHosts: string[];
39
+ blockedHosts: string[];
40
+ allowInternalIPs: boolean;
41
+ }
42
+ /** Resolved DNS address shape (subset of Node's `dns.LookupAddress`). */
43
+ export interface ResolvedAddress {
44
+ address: string;
45
+ family: number;
46
+ }
47
+ /** Hostname → resolved addresses. Injectable for testing. */
48
+ export type SsrfHostLookup = (hostname: string) => Promise<ResolvedAddress[]>;
49
+ /**
50
+ * Non-IP hostnames that map to internal targets, so the IP-range checks alone
51
+ * would miss them when DNS resolution is unavailable. DNS resolution (when
52
+ * available) also catches these; this set is defense-in-depth.
53
+ */
54
+ export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
55
+ /** Normalize a (possibly undefined) {@link RefResolutionOptions} to the SSRF subset. */
56
+ export declare function normalizeSsrfOptions(refResolution?: RefResolutionOptions): ResolvedSsrfOptions;
57
+ /**
58
+ * Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
59
+ * `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4, or
60
+ * `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
61
+ * `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
62
+ * dotted-quad range checks would otherwise miss.
63
+ */
64
+ export declare function decodeIpv4MappedIpv6(hostname: string): string | null;
65
+ /**
66
+ * Predicate: is `host` (an IP literal — dotted-quad IPv4, bracketed/zoned IPv6,
67
+ * or IPv4-mapped IPv6) in a blocked, non-public range? Returns `false` for
68
+ * non-IP-literal hostnames (use DNS resolution for those).
69
+ */
70
+ export declare function isBlockedAddress(host: string): boolean;
71
+ /**
72
+ * Synchronous host check used by the `$RefParser` `canRead` filter (which cannot
73
+ * be async). Blocks known internal hostnames, explicit `blockedHosts`, and
74
+ * literal internal IPs (incl. IPv4-mapped IPv6). DNS names that *resolve* to
75
+ * internal addresses are caught later, asynchronously, in {@link safeFetch}.
76
+ */
77
+ export declare function isBlockedHostname(hostname: string, ssrf: ResolvedSsrfOptions): boolean;
78
+ /** Default DNS resolver: lazily loads `node:dns`; rejects on non-Node runtimes. */
79
+ export declare const defaultLookup: SsrfHostLookup;
80
+ /**
81
+ * Validate that `url` is safe to fetch (spec URL or `$ref` target), throwing
82
+ * {@link SsrfError} if not. Enforces http/https, the `allowedHosts` allow-list,
83
+ * the internal-address denylist, and — for DNS names — resolves and rejects if
84
+ * any resolved address is internal.
85
+ */
86
+ export declare function assertUrlSafe(url: string, ssrf: ResolvedSsrfOptions, lookup?: SsrfHostLookup): Promise<void>;
87
+ /** Options for {@link safeFetch}. */
88
+ export interface SafeFetchOptions {
89
+ headers?: Record<string, string>;
90
+ timeoutMs?: number;
91
+ /** Follow 3xx redirects (re-validating each hop). @default true */
92
+ followRedirects?: boolean;
93
+ /** Max redirect hops before failing. @default 5 */
94
+ maxRedirects?: number;
95
+ ssrf: ResolvedSsrfOptions;
96
+ /** Injectable DNS resolver (tests). */
97
+ lookup?: SsrfHostLookup;
98
+ /** Injectable fetch implementation (tests / custom runtimes). */
99
+ fetchImpl?: typeof fetch;
100
+ }
101
+ /**
102
+ * 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).
107
+ */
108
+ export declare function safeFetch(url: string, opts: SafeFetchOptions): Promise<Response>;
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;