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 +13 -0
- package/esm/index.mjs +224 -94
- package/esm/package.json +1 -1
- package/generator.d.ts +0 -18
- package/index.d.ts +3 -1
- package/index.js +233 -94
- package/package.json +1 -1
- package/ssrf.d.ts +108 -0
- package/types.d.ts +16 -6
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
|
|
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
|
-
|
|
825
|
-
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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`
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
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 (
|
|
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
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
|
|
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
|
-
|
|
877
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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`
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
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 (
|
|
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
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
|
|
331
|
-
*
|
|
332
|
-
*
|
|
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
|
|
395
|
-
*
|
|
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;
|