mcp-from-openapi 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/errors.d.ts +13 -0
- package/esm/index.mjs +321 -94
- package/esm/package.json +1 -1
- package/generator.d.ts +0 -18
- package/index.d.ts +3 -1
- package/index.js +330 -94
- package/package.json +1 -1
- package/ssrf.d.ts +170 -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,275 @@ function resolveSchemaFormats(schema, resolvers) {
|
|
|
792
797
|
return result;
|
|
793
798
|
}
|
|
794
799
|
|
|
800
|
+
// src/ssrf.ts
|
|
801
|
+
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
802
|
+
"localhost",
|
|
803
|
+
"localhost.localdomain",
|
|
804
|
+
"ip6-localhost",
|
|
805
|
+
"ip6-loopback",
|
|
806
|
+
"metadata",
|
|
807
|
+
"metadata.google.internal",
|
|
808
|
+
"metadata.goog"
|
|
809
|
+
]);
|
|
810
|
+
function normalizeSsrfOptions(refResolution) {
|
|
811
|
+
return {
|
|
812
|
+
allowedHosts: refResolution?.allowedHosts ?? [],
|
|
813
|
+
blockedHosts: refResolution?.blockedHosts ?? [],
|
|
814
|
+
allowInternalIPs: refResolution?.allowInternalIPs ?? false
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
function decodeIpv4MappedIpv6(hostname) {
|
|
818
|
+
let h = hostname;
|
|
819
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
820
|
+
const lower = h.toLowerCase();
|
|
821
|
+
const marker = lower.lastIndexOf("::ffff:");
|
|
822
|
+
if (marker === -1) return null;
|
|
823
|
+
const tail = lower.slice(marker + "::ffff:".length);
|
|
824
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
|
|
825
|
+
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
826
|
+
if (hex) {
|
|
827
|
+
const hi = parseInt(hex[1], 16);
|
|
828
|
+
const lo = parseInt(hex[2], 16);
|
|
829
|
+
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
|
830
|
+
return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
|
|
831
|
+
}
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
function parseIpv4(host) {
|
|
835
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
836
|
+
if (!m) return null;
|
|
837
|
+
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
838
|
+
if (octets.some((n) => n > 255)) return null;
|
|
839
|
+
return octets;
|
|
840
|
+
}
|
|
841
|
+
function isBlockedIpv4(octets) {
|
|
842
|
+
const [a, b, c] = octets;
|
|
843
|
+
if (a === 0) return true;
|
|
844
|
+
if (a === 10) return true;
|
|
845
|
+
if (a === 127) return true;
|
|
846
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
847
|
+
if (a === 169 && b === 254) return true;
|
|
848
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
849
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
850
|
+
if (a === 192 && b === 168) return true;
|
|
851
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
852
|
+
if (a >= 224) return true;
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
function isBlockedIpv6(host) {
|
|
856
|
+
let h = host;
|
|
857
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
858
|
+
const zone = h.indexOf("%");
|
|
859
|
+
if (zone !== -1) h = h.slice(0, zone);
|
|
860
|
+
const lower = h.toLowerCase();
|
|
861
|
+
if (lower === "::" || lower === "::0") return true;
|
|
862
|
+
if (lower === "::1") return true;
|
|
863
|
+
if (/^f[cd]/.test(lower)) return true;
|
|
864
|
+
if (/^fe[89a-f]/.test(lower)) return true;
|
|
865
|
+
if (/^ff/.test(lower)) return true;
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
function isBlockedAddress(host) {
|
|
869
|
+
let h = host;
|
|
870
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
871
|
+
const mapped = decodeIpv4MappedIpv6(host);
|
|
872
|
+
if (mapped) {
|
|
873
|
+
const o = parseIpv4(mapped);
|
|
874
|
+
if (o) return isBlockedIpv4(o);
|
|
875
|
+
}
|
|
876
|
+
const v4 = parseIpv4(h);
|
|
877
|
+
if (v4) return isBlockedIpv4(v4);
|
|
878
|
+
if (h.includes(":")) return isBlockedIpv6(h);
|
|
879
|
+
return false;
|
|
880
|
+
}
|
|
881
|
+
function isIpLiteral(hostname) {
|
|
882
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
|
|
883
|
+
return parseIpv4(hostname) !== null;
|
|
884
|
+
}
|
|
885
|
+
function isBlockedHostname(hostname, ssrf) {
|
|
886
|
+
if (ssrf.allowInternalIPs) {
|
|
887
|
+
return ssrf.blockedHosts.includes(hostname);
|
|
888
|
+
}
|
|
889
|
+
if (ssrf.blockedHosts.includes(hostname)) return true;
|
|
890
|
+
const lower = hostname.toLowerCase();
|
|
891
|
+
const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
892
|
+
if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
|
|
893
|
+
return isBlockedAddress(hostname);
|
|
894
|
+
}
|
|
895
|
+
var SsrfResolverUnavailableError = class extends Error {
|
|
896
|
+
};
|
|
897
|
+
var defaultLookup = async (hostname) => {
|
|
898
|
+
let dns;
|
|
899
|
+
try {
|
|
900
|
+
dns = await import("node:dns");
|
|
901
|
+
} catch {
|
|
902
|
+
throw new SsrfResolverUnavailableError("DNS resolution is unavailable on this runtime");
|
|
903
|
+
}
|
|
904
|
+
return dns.promises.lookup(hostname, { all: true });
|
|
905
|
+
};
|
|
906
|
+
async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
|
|
907
|
+
let parsed;
|
|
908
|
+
try {
|
|
909
|
+
parsed = new URL(url);
|
|
910
|
+
} catch {
|
|
911
|
+
throw new SsrfError(`Invalid spec URL: ${url}`, { url });
|
|
912
|
+
}
|
|
913
|
+
const protocol = parsed.protocol.replace(/:$/, "");
|
|
914
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
915
|
+
throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
|
|
916
|
+
}
|
|
917
|
+
const hostname = parsed.hostname;
|
|
918
|
+
if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
|
|
919
|
+
throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
|
|
920
|
+
}
|
|
921
|
+
if (ssrf.allowInternalIPs) {
|
|
922
|
+
if (ssrf.blockedHosts.includes(hostname)) {
|
|
923
|
+
throw new SsrfError(`Host "${hostname}" is blocked`, { url });
|
|
924
|
+
}
|
|
925
|
+
return [];
|
|
926
|
+
}
|
|
927
|
+
if (isBlockedHostname(hostname, ssrf)) {
|
|
928
|
+
throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
|
|
929
|
+
}
|
|
930
|
+
if (isIpLiteral(hostname)) {
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
let addresses;
|
|
934
|
+
try {
|
|
935
|
+
addresses = await lookup(hostname);
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (error instanceof SsrfResolverUnavailableError) {
|
|
938
|
+
return [];
|
|
939
|
+
}
|
|
940
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
941
|
+
throw new SsrfError(`Host "${hostname}" could not be resolved for SSRF validation: ${message}`, { url });
|
|
942
|
+
}
|
|
943
|
+
if (addresses.length === 0) {
|
|
944
|
+
throw new SsrfError(`Host "${hostname}" did not resolve to any address`, { url });
|
|
945
|
+
}
|
|
946
|
+
for (const { address } of addresses) {
|
|
947
|
+
if (isBlockedAddress(address)) {
|
|
948
|
+
throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
return addresses;
|
|
952
|
+
}
|
|
953
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
954
|
+
async function loadNodeHttpModules() {
|
|
955
|
+
try {
|
|
956
|
+
const [http, https] = await Promise.all([import("node:http"), import("node:https")]);
|
|
957
|
+
return { http, https };
|
|
958
|
+
} catch {
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function pickHttpModule(protocol, modules) {
|
|
963
|
+
return protocol === "https:" ? modules.https : modules.http;
|
|
964
|
+
}
|
|
965
|
+
function makePinnedLookup(pinned) {
|
|
966
|
+
return (_hostname, options, callback) => {
|
|
967
|
+
const done = typeof options === "function" ? options : callback;
|
|
968
|
+
const wantsAll = typeof options === "object" && options !== null && options.all === true;
|
|
969
|
+
if (wantsAll) {
|
|
970
|
+
done(
|
|
971
|
+
null,
|
|
972
|
+
pinned.map(({ address, family }) => ({ address, family }))
|
|
973
|
+
);
|
|
974
|
+
} else {
|
|
975
|
+
done(null, pinned[0].address, pinned[0].family);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
980
|
+
function nodePinnedTransport(modules) {
|
|
981
|
+
return (url, { headers, signal, pinned, maxBytes }) => new Promise((resolve, reject) => {
|
|
982
|
+
const limit = maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
983
|
+
const lib = pickHttpModule(new URL(url).protocol, modules);
|
|
984
|
+
const requestOptions = {
|
|
985
|
+
method: "GET",
|
|
986
|
+
signal,
|
|
987
|
+
headers: { ...headers, "accept-encoding": "identity" }
|
|
988
|
+
};
|
|
989
|
+
if (pinned.length > 0) {
|
|
990
|
+
requestOptions["lookup"] = makePinnedLookup(pinned);
|
|
991
|
+
}
|
|
992
|
+
const request = lib.request(url, requestOptions, (response) => {
|
|
993
|
+
const chunks = [];
|
|
994
|
+
let received = 0;
|
|
995
|
+
response.on("data", (chunk) => {
|
|
996
|
+
received += chunk.length;
|
|
997
|
+
if (received > limit) {
|
|
998
|
+
request.destroy();
|
|
999
|
+
reject(new SsrfError(`Response body exceeds ${limit} bytes`, { url }));
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
chunks.push(chunk);
|
|
1003
|
+
});
|
|
1004
|
+
response.on("end", () => {
|
|
1005
|
+
const status = response.statusCode;
|
|
1006
|
+
const responseHeaders = new Headers();
|
|
1007
|
+
const entries = Object.entries(response.headers);
|
|
1008
|
+
for (const [key, value] of entries) {
|
|
1009
|
+
if (Array.isArray(value)) {
|
|
1010
|
+
for (const item of value) responseHeaders.append(key, item);
|
|
1011
|
+
} else {
|
|
1012
|
+
responseHeaders.append(key, value);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
const body = NULL_BODY_STATUS.has(status) ? null : Buffer.concat(chunks);
|
|
1016
|
+
resolve(new Response(body, { status, statusText: response.statusMessage, headers: responseHeaders }));
|
|
1017
|
+
});
|
|
1018
|
+
response.on("error", reject);
|
|
1019
|
+
});
|
|
1020
|
+
request.on("error", reject);
|
|
1021
|
+
request.end();
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
function fetchTransport(fetchImpl) {
|
|
1025
|
+
return (url, { headers, signal }) => fetchImpl(url, { headers, signal, redirect: "manual" });
|
|
1026
|
+
}
|
|
1027
|
+
async function selectTransport(opts, url) {
|
|
1028
|
+
if (opts.fetchImpl) {
|
|
1029
|
+
return fetchTransport(opts.fetchImpl);
|
|
1030
|
+
}
|
|
1031
|
+
const modules = await loadNodeHttpModules();
|
|
1032
|
+
if (!modules) {
|
|
1033
|
+
const platformFetch = globalThis.fetch;
|
|
1034
|
+
if (typeof platformFetch === "function") {
|
|
1035
|
+
return fetchTransport(platformFetch);
|
|
1036
|
+
}
|
|
1037
|
+
throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
|
|
1038
|
+
}
|
|
1039
|
+
return nodePinnedTransport(modules);
|
|
1040
|
+
}
|
|
1041
|
+
async function safeFetch(url, opts) {
|
|
1042
|
+
const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
|
|
1043
|
+
const transport = await selectTransport(opts, url);
|
|
1044
|
+
let current = url;
|
|
1045
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1046
|
+
const pinned = await assertUrlSafe(current, ssrf, lookup);
|
|
1047
|
+
const controller = new AbortController();
|
|
1048
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1049
|
+
let response;
|
|
1050
|
+
try {
|
|
1051
|
+
response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
|
|
1052
|
+
} finally {
|
|
1053
|
+
clearTimeout(timer);
|
|
1054
|
+
}
|
|
1055
|
+
const status = typeof response.status === "number" ? response.status : 0;
|
|
1056
|
+
const isRedirect = status >= 300 && status < 400 && status !== 304;
|
|
1057
|
+
if (!isRedirect || !followRedirects) {
|
|
1058
|
+
return response;
|
|
1059
|
+
}
|
|
1060
|
+
const location = response.headers?.get?.("location") ?? void 0;
|
|
1061
|
+
if (!location) {
|
|
1062
|
+
return response;
|
|
1063
|
+
}
|
|
1064
|
+
current = new URL(location, current).toString();
|
|
1065
|
+
}
|
|
1066
|
+
throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
795
1069
|
// src/generator.ts
|
|
796
1070
|
var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
797
1071
|
document;
|
|
@@ -817,14 +1091,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
817
1091
|
*/
|
|
818
1092
|
static async fromURL(url, options = {}) {
|
|
819
1093
|
try {
|
|
820
|
-
const
|
|
821
|
-
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
|
|
822
|
-
const response = await fetch(url, {
|
|
1094
|
+
const response = await safeFetch(url, {
|
|
823
1095
|
headers: options.headers,
|
|
824
|
-
|
|
825
|
-
|
|
1096
|
+
timeoutMs: options.timeout ?? 3e4,
|
|
1097
|
+
followRedirects: options.followRedirects ?? true,
|
|
1098
|
+
ssrf: normalizeSsrfOptions(options.refResolution)
|
|
826
1099
|
});
|
|
827
|
-
clearTimeout(timeout);
|
|
828
1100
|
if (!response.ok) {
|
|
829
1101
|
throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
|
|
830
1102
|
url,
|
|
@@ -915,87 +1187,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
915
1187
|
const validator = new Validator();
|
|
916
1188
|
return validator.validate(this.document);
|
|
917
1189
|
}
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
}
|
|
1190
|
+
// NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
|
|
1191
|
+
// in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
|
|
1192
|
+
// shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
|
|
1193
|
+
// augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
|
|
1194
|
+
// and per-hop redirect re-validation (`safeFetch`).
|
|
999
1195
|
/**
|
|
1000
1196
|
* Build $RefParser options based on refResolution configuration.
|
|
1001
1197
|
* Defaults: allow http/https, block file://, block internal IPs.
|
|
@@ -1022,13 +1218,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
1022
1218
|
const hostAllowSet = new Set(refOpts.allowedHosts);
|
|
1023
1219
|
resolveConfig["http"] = {
|
|
1024
1220
|
// SECURITY: never auto-follow HTTP redirects when resolving external
|
|
1025
|
-
// `$ref`s. `canRead`
|
|
1026
|
-
//
|
|
1027
|
-
//
|
|
1028
|
-
//
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1221
|
+
// `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
|
|
1222
|
+
// default redirect-following (up to 5 hops) re-fetches the `Location`
|
|
1223
|
+
// target WITHOUT re-invoking `canRead`, so an allowlisted host could
|
|
1224
|
+
// 302 → `http://169.254.169.254/...` and smuggle a blocked target past
|
|
1225
|
+
// the allow/deny lists. `redirects: 0` refuses the first redirect, and
|
|
1226
|
+
// our custom `read` (below) additionally refuses redirects itself.
|
|
1031
1227
|
redirects: 0,
|
|
1228
|
+
// Synchronous gate: protocol, host allow-list, and literal/known
|
|
1229
|
+
// internal hosts. DNS names that *resolve* to internal addresses pass
|
|
1230
|
+
// here (canRead cannot be async) and are caught in `read` via DNS
|
|
1231
|
+
// resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
|
|
1032
1232
|
canRead: (file) => {
|
|
1033
1233
|
try {
|
|
1034
1234
|
const parsed = new URL(file.url);
|
|
@@ -1039,13 +1239,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
1039
1239
|
if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
|
|
1040
1240
|
return false;
|
|
1041
1241
|
}
|
|
1042
|
-
if (
|
|
1242
|
+
if (isBlockedHostname(parsed.hostname, refOpts)) {
|
|
1043
1243
|
return false;
|
|
1044
1244
|
}
|
|
1045
1245
|
return true;
|
|
1046
1246
|
} catch {
|
|
1047
1247
|
return false;
|
|
1048
1248
|
}
|
|
1249
|
+
},
|
|
1250
|
+
// SSRF-safe fetch: resolves DNS and rejects names that map to internal
|
|
1251
|
+
// addresses, and refuses redirects. NOTE: deliberately does NOT forward
|
|
1252
|
+
// `this.options.headers` (the spec-load credentials) to third-party
|
|
1253
|
+
// `$ref` hosts — that would leak the spec's auth token cross-origin.
|
|
1254
|
+
read: async (file) => {
|
|
1255
|
+
const response = await safeFetch(file.url, {
|
|
1256
|
+
timeoutMs: this.options.timeout,
|
|
1257
|
+
followRedirects: false,
|
|
1258
|
+
ssrf: refOpts
|
|
1259
|
+
});
|
|
1260
|
+
if (!response.ok) {
|
|
1261
|
+
throw new LoadError(
|
|
1262
|
+
`Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
|
|
1263
|
+
{ url: file.url, status: response.status }
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
return response.text();
|
|
1049
1267
|
}
|
|
1050
1268
|
};
|
|
1051
1269
|
} else {
|
|
@@ -1857,6 +2075,7 @@ function createSecurityContext(auth) {
|
|
|
1857
2075
|
};
|
|
1858
2076
|
}
|
|
1859
2077
|
export {
|
|
2078
|
+
BLOCKED_HOSTNAMES,
|
|
1860
2079
|
BUILTIN_FORMAT_RESOLVERS,
|
|
1861
2080
|
GenerationError,
|
|
1862
2081
|
LoadError,
|
|
@@ -1868,10 +2087,18 @@ export {
|
|
|
1868
2087
|
SchemaBuilder,
|
|
1869
2088
|
SchemaError,
|
|
1870
2089
|
SecurityResolver,
|
|
2090
|
+
SsrfError,
|
|
1871
2091
|
ValidationError,
|
|
1872
2092
|
Validator,
|
|
2093
|
+
assertUrlSafe,
|
|
1873
2094
|
createSecurityContext,
|
|
2095
|
+
decodeIpv4MappedIpv6,
|
|
2096
|
+
defaultLookup,
|
|
2097
|
+
isBlockedAddress,
|
|
2098
|
+
isBlockedHostname,
|
|
1874
2099
|
isReferenceObject,
|
|
2100
|
+
normalizeSsrfOptions,
|
|
1875
2101
|
resolveSchemaFormats,
|
|
2102
|
+
safeFetch,
|
|
1876
2103
|
toJsonSchema
|
|
1877
2104
|
};
|
package/esm/package.json
CHANGED
package/generator.d.ts
CHANGED
|
@@ -34,24 +34,6 @@ export declare class OpenAPIToolGenerator {
|
|
|
34
34
|
* Validate the OpenAPI document
|
|
35
35
|
*/
|
|
36
36
|
validate(): Promise<ValidationResult>;
|
|
37
|
-
/**
|
|
38
|
-
* Hostnames and IP patterns that are blocked by default to prevent SSRF.
|
|
39
|
-
* Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
|
|
40
|
-
*/
|
|
41
|
-
private static readonly BLOCKED_HOSTNAME_PATTERNS;
|
|
42
|
-
/**
|
|
43
|
-
* Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
|
|
44
|
-
* `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4,
|
|
45
|
-
* or `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
|
|
46
|
-
* `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
|
|
47
|
-
* dotted-quad blocklist patterns miss — letting an attacker reach a private /
|
|
48
|
-
* metadata IPv4 through the v6 mapping. We re-check the decoded v4.
|
|
49
|
-
*/
|
|
50
|
-
private static mappedIPv4FromIPv6;
|
|
51
|
-
/**
|
|
52
|
-
* Check whether a hostname is blocked (internal/private IP or explicit blocklist).
|
|
53
|
-
*/
|
|
54
|
-
private isBlockedHost;
|
|
55
37
|
/**
|
|
56
38
|
* Build $RefParser options based on refResolution configuration.
|
|
57
39
|
* Defaults: allow http/https, block file://, block internal IPs.
|
package/index.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export { ResponseBuilder } from './response-builder';
|
|
|
5
5
|
export { Validator } from './validator';
|
|
6
6
|
export { SecurityResolver, createSecurityContext } from './security-resolver';
|
|
7
7
|
export { BUILTIN_FORMAT_RESOLVERS, resolveSchemaFormats } from './format-resolver';
|
|
8
|
-
export { OpenAPIToolError, LoadError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
|
|
8
|
+
export { OpenAPIToolError, LoadError, SsrfError, ParseError, ValidationError, GenerationError, SchemaError } from './errors';
|
|
9
|
+
export { assertUrlSafe, safeFetch, isBlockedAddress, isBlockedHostname, decodeIpv4MappedIpv6, normalizeSsrfOptions, defaultLookup, BLOCKED_HOSTNAMES, } from './ssrf';
|
|
10
|
+
export type { ResolvedSsrfOptions, ResolvedAddress, SsrfHostLookup, SafeFetchOptions } from './ssrf';
|
|
9
11
|
export type { McpOpenAPITool, ParameterMapper, ToolMetadata, FrontMcpExtensionData, SerializationInfo, SecurityRequirement, SecurityParameterInfo, ServerInfo, RefResolutionOptions, LoadOptions, GenerateOptions, FormatResolver, JsonSchema, NamingStrategy, OperationWithContext, OpenAPIDocument, OpenAPIVersion, HTTPMethod, ParameterLocation, AuthType, OperationObject, ParameterObject, RequestBodyObject, ResponseObject, ResponsesObject, MediaTypeObject, HeaderObject, ExampleObject, PathItemObject, PathsObject, ServerObject, SecuritySchemeObject, ReferenceObject, TagObject, ExternalDocumentationObject, ServerVariableObject, EncodingObject, SecurityRequirementObject, SchemaObject, ValidationResult, ValidationErrorDetail, ValidationWarning, } from './types';
|
|
10
12
|
export type { SecurityContext, ResolvedSecurity, DigestAuthCredentials, ClientCertificate, AWSCredentials, SignatureData, } from './security-resolver';
|
|
11
13
|
export { isReferenceObject, toJsonSchema } from './types';
|
package/index.js
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
BLOCKED_HOSTNAMES: () => BLOCKED_HOSTNAMES,
|
|
33
34
|
BUILTIN_FORMAT_RESOLVERS: () => BUILTIN_FORMAT_RESOLVERS,
|
|
34
35
|
GenerationError: () => GenerationError,
|
|
35
36
|
LoadError: () => LoadError,
|
|
@@ -41,11 +42,19 @@ __export(index_exports, {
|
|
|
41
42
|
SchemaBuilder: () => SchemaBuilder,
|
|
42
43
|
SchemaError: () => SchemaError,
|
|
43
44
|
SecurityResolver: () => SecurityResolver,
|
|
45
|
+
SsrfError: () => SsrfError,
|
|
44
46
|
ValidationError: () => ValidationError,
|
|
45
47
|
Validator: () => Validator,
|
|
48
|
+
assertUrlSafe: () => assertUrlSafe,
|
|
46
49
|
createSecurityContext: () => createSecurityContext,
|
|
50
|
+
decodeIpv4MappedIpv6: () => decodeIpv4MappedIpv6,
|
|
51
|
+
defaultLookup: () => defaultLookup,
|
|
52
|
+
isBlockedAddress: () => isBlockedAddress,
|
|
53
|
+
isBlockedHostname: () => isBlockedHostname,
|
|
47
54
|
isReferenceObject: () => isReferenceObject,
|
|
55
|
+
normalizeSsrfOptions: () => normalizeSsrfOptions,
|
|
48
56
|
resolveSchemaFormats: () => resolveSchemaFormats,
|
|
57
|
+
safeFetch: () => safeFetch,
|
|
49
58
|
toJsonSchema: () => toJsonSchema
|
|
50
59
|
});
|
|
51
60
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -712,6 +721,11 @@ var LoadError = class extends OpenAPIToolError {
|
|
|
712
721
|
super(message, context);
|
|
713
722
|
}
|
|
714
723
|
};
|
|
724
|
+
var SsrfError = class extends LoadError {
|
|
725
|
+
constructor(message, context) {
|
|
726
|
+
super(message, context);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
715
729
|
var ParseError = class extends OpenAPIToolError {
|
|
716
730
|
constructor(message, context) {
|
|
717
731
|
super(message, context);
|
|
@@ -844,6 +858,275 @@ function resolveSchemaFormats(schema, resolvers) {
|
|
|
844
858
|
return result;
|
|
845
859
|
}
|
|
846
860
|
|
|
861
|
+
// src/ssrf.ts
|
|
862
|
+
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
|
|
863
|
+
"localhost",
|
|
864
|
+
"localhost.localdomain",
|
|
865
|
+
"ip6-localhost",
|
|
866
|
+
"ip6-loopback",
|
|
867
|
+
"metadata",
|
|
868
|
+
"metadata.google.internal",
|
|
869
|
+
"metadata.goog"
|
|
870
|
+
]);
|
|
871
|
+
function normalizeSsrfOptions(refResolution) {
|
|
872
|
+
return {
|
|
873
|
+
allowedHosts: refResolution?.allowedHosts ?? [],
|
|
874
|
+
blockedHosts: refResolution?.blockedHosts ?? [],
|
|
875
|
+
allowInternalIPs: refResolution?.allowInternalIPs ?? false
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function decodeIpv4MappedIpv6(hostname) {
|
|
879
|
+
let h = hostname;
|
|
880
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
881
|
+
const lower = h.toLowerCase();
|
|
882
|
+
const marker = lower.lastIndexOf("::ffff:");
|
|
883
|
+
if (marker === -1) return null;
|
|
884
|
+
const tail = lower.slice(marker + "::ffff:".length);
|
|
885
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(tail)) return tail;
|
|
886
|
+
const hex = tail.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
887
|
+
if (hex) {
|
|
888
|
+
const hi = parseInt(hex[1], 16);
|
|
889
|
+
const lo = parseInt(hex[2], 16);
|
|
890
|
+
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;
|
|
891
|
+
return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
function parseIpv4(host) {
|
|
896
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
897
|
+
if (!m) return null;
|
|
898
|
+
const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
|
|
899
|
+
if (octets.some((n) => n > 255)) return null;
|
|
900
|
+
return octets;
|
|
901
|
+
}
|
|
902
|
+
function isBlockedIpv4(octets) {
|
|
903
|
+
const [a, b, c] = octets;
|
|
904
|
+
if (a === 0) return true;
|
|
905
|
+
if (a === 10) return true;
|
|
906
|
+
if (a === 127) return true;
|
|
907
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
908
|
+
if (a === 169 && b === 254) return true;
|
|
909
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
910
|
+
if (a === 192 && b === 0 && c === 0) return true;
|
|
911
|
+
if (a === 192 && b === 168) return true;
|
|
912
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
913
|
+
if (a >= 224) return true;
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
function isBlockedIpv6(host) {
|
|
917
|
+
let h = host;
|
|
918
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
919
|
+
const zone = h.indexOf("%");
|
|
920
|
+
if (zone !== -1) h = h.slice(0, zone);
|
|
921
|
+
const lower = h.toLowerCase();
|
|
922
|
+
if (lower === "::" || lower === "::0") return true;
|
|
923
|
+
if (lower === "::1") return true;
|
|
924
|
+
if (/^f[cd]/.test(lower)) return true;
|
|
925
|
+
if (/^fe[89a-f]/.test(lower)) return true;
|
|
926
|
+
if (/^ff/.test(lower)) return true;
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
function isBlockedAddress(host) {
|
|
930
|
+
let h = host;
|
|
931
|
+
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
|
932
|
+
const mapped = decodeIpv4MappedIpv6(host);
|
|
933
|
+
if (mapped) {
|
|
934
|
+
const o = parseIpv4(mapped);
|
|
935
|
+
if (o) return isBlockedIpv4(o);
|
|
936
|
+
}
|
|
937
|
+
const v4 = parseIpv4(h);
|
|
938
|
+
if (v4) return isBlockedIpv4(v4);
|
|
939
|
+
if (h.includes(":")) return isBlockedIpv6(h);
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
function isIpLiteral(hostname) {
|
|
943
|
+
if (hostname.startsWith("[") && hostname.endsWith("]")) return true;
|
|
944
|
+
return parseIpv4(hostname) !== null;
|
|
945
|
+
}
|
|
946
|
+
function isBlockedHostname(hostname, ssrf) {
|
|
947
|
+
if (ssrf.allowInternalIPs) {
|
|
948
|
+
return ssrf.blockedHosts.includes(hostname);
|
|
949
|
+
}
|
|
950
|
+
if (ssrf.blockedHosts.includes(hostname)) return true;
|
|
951
|
+
const lower = hostname.toLowerCase();
|
|
952
|
+
const stripped = lower.startsWith("[") && lower.endsWith("]") ? lower.slice(1, -1) : lower;
|
|
953
|
+
if (BLOCKED_HOSTNAMES.has(lower) || BLOCKED_HOSTNAMES.has(stripped)) return true;
|
|
954
|
+
return isBlockedAddress(hostname);
|
|
955
|
+
}
|
|
956
|
+
var SsrfResolverUnavailableError = class extends Error {
|
|
957
|
+
};
|
|
958
|
+
var defaultLookup = async (hostname) => {
|
|
959
|
+
let dns;
|
|
960
|
+
try {
|
|
961
|
+
dns = await import("node:dns");
|
|
962
|
+
} catch {
|
|
963
|
+
throw new SsrfResolverUnavailableError("DNS resolution is unavailable on this runtime");
|
|
964
|
+
}
|
|
965
|
+
return dns.promises.lookup(hostname, { all: true });
|
|
966
|
+
};
|
|
967
|
+
async function assertUrlSafe(url, ssrf, lookup = defaultLookup) {
|
|
968
|
+
let parsed;
|
|
969
|
+
try {
|
|
970
|
+
parsed = new URL(url);
|
|
971
|
+
} catch {
|
|
972
|
+
throw new SsrfError(`Invalid spec URL: ${url}`, { url });
|
|
973
|
+
}
|
|
974
|
+
const protocol = parsed.protocol.replace(/:$/, "");
|
|
975
|
+
if (protocol !== "http" && protocol !== "https") {
|
|
976
|
+
throw new SsrfError(`Protocol "${protocol}" is not allowed for network spec loading (only http/https)`, { url });
|
|
977
|
+
}
|
|
978
|
+
const hostname = parsed.hostname;
|
|
979
|
+
if (ssrf.allowedHosts.length > 0 && !ssrf.allowedHosts.includes(hostname)) {
|
|
980
|
+
throw new SsrfError(`Host "${hostname}" is not in the allowed-hosts list`, { url });
|
|
981
|
+
}
|
|
982
|
+
if (ssrf.allowInternalIPs) {
|
|
983
|
+
if (ssrf.blockedHosts.includes(hostname)) {
|
|
984
|
+
throw new SsrfError(`Host "${hostname}" is blocked`, { url });
|
|
985
|
+
}
|
|
986
|
+
return [];
|
|
987
|
+
}
|
|
988
|
+
if (isBlockedHostname(hostname, ssrf)) {
|
|
989
|
+
throw new SsrfError(`Host "${hostname}" maps to a blocked internal address`, { url });
|
|
990
|
+
}
|
|
991
|
+
if (isIpLiteral(hostname)) {
|
|
992
|
+
return [];
|
|
993
|
+
}
|
|
994
|
+
let addresses;
|
|
995
|
+
try {
|
|
996
|
+
addresses = await lookup(hostname);
|
|
997
|
+
} catch (error) {
|
|
998
|
+
if (error instanceof SsrfResolverUnavailableError) {
|
|
999
|
+
return [];
|
|
1000
|
+
}
|
|
1001
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1002
|
+
throw new SsrfError(`Host "${hostname}" could not be resolved for SSRF validation: ${message}`, { url });
|
|
1003
|
+
}
|
|
1004
|
+
if (addresses.length === 0) {
|
|
1005
|
+
throw new SsrfError(`Host "${hostname}" did not resolve to any address`, { url });
|
|
1006
|
+
}
|
|
1007
|
+
for (const { address } of addresses) {
|
|
1008
|
+
if (isBlockedAddress(address)) {
|
|
1009
|
+
throw new SsrfError(`Host "${hostname}" resolves to blocked address ${address}`, { url });
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return addresses;
|
|
1013
|
+
}
|
|
1014
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
1015
|
+
async function loadNodeHttpModules() {
|
|
1016
|
+
try {
|
|
1017
|
+
const [http, https] = await Promise.all([import("node:http"), import("node:https")]);
|
|
1018
|
+
return { http, https };
|
|
1019
|
+
} catch {
|
|
1020
|
+
return null;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
function pickHttpModule(protocol, modules) {
|
|
1024
|
+
return protocol === "https:" ? modules.https : modules.http;
|
|
1025
|
+
}
|
|
1026
|
+
function makePinnedLookup(pinned) {
|
|
1027
|
+
return (_hostname, options, callback) => {
|
|
1028
|
+
const done = typeof options === "function" ? options : callback;
|
|
1029
|
+
const wantsAll = typeof options === "object" && options !== null && options.all === true;
|
|
1030
|
+
if (wantsAll) {
|
|
1031
|
+
done(
|
|
1032
|
+
null,
|
|
1033
|
+
pinned.map(({ address, family }) => ({ address, family }))
|
|
1034
|
+
);
|
|
1035
|
+
} else {
|
|
1036
|
+
done(null, pinned[0].address, pinned[0].family);
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
var NULL_BODY_STATUS = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
1041
|
+
function nodePinnedTransport(modules) {
|
|
1042
|
+
return (url, { headers, signal, pinned, maxBytes }) => new Promise((resolve, reject) => {
|
|
1043
|
+
const limit = maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
1044
|
+
const lib = pickHttpModule(new URL(url).protocol, modules);
|
|
1045
|
+
const requestOptions = {
|
|
1046
|
+
method: "GET",
|
|
1047
|
+
signal,
|
|
1048
|
+
headers: { ...headers, "accept-encoding": "identity" }
|
|
1049
|
+
};
|
|
1050
|
+
if (pinned.length > 0) {
|
|
1051
|
+
requestOptions["lookup"] = makePinnedLookup(pinned);
|
|
1052
|
+
}
|
|
1053
|
+
const request = lib.request(url, requestOptions, (response) => {
|
|
1054
|
+
const chunks = [];
|
|
1055
|
+
let received = 0;
|
|
1056
|
+
response.on("data", (chunk) => {
|
|
1057
|
+
received += chunk.length;
|
|
1058
|
+
if (received > limit) {
|
|
1059
|
+
request.destroy();
|
|
1060
|
+
reject(new SsrfError(`Response body exceeds ${limit} bytes`, { url }));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
chunks.push(chunk);
|
|
1064
|
+
});
|
|
1065
|
+
response.on("end", () => {
|
|
1066
|
+
const status = response.statusCode;
|
|
1067
|
+
const responseHeaders = new Headers();
|
|
1068
|
+
const entries = Object.entries(response.headers);
|
|
1069
|
+
for (const [key, value] of entries) {
|
|
1070
|
+
if (Array.isArray(value)) {
|
|
1071
|
+
for (const item of value) responseHeaders.append(key, item);
|
|
1072
|
+
} else {
|
|
1073
|
+
responseHeaders.append(key, value);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const body = NULL_BODY_STATUS.has(status) ? null : Buffer.concat(chunks);
|
|
1077
|
+
resolve(new Response(body, { status, statusText: response.statusMessage, headers: responseHeaders }));
|
|
1078
|
+
});
|
|
1079
|
+
response.on("error", reject);
|
|
1080
|
+
});
|
|
1081
|
+
request.on("error", reject);
|
|
1082
|
+
request.end();
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
function fetchTransport(fetchImpl) {
|
|
1086
|
+
return (url, { headers, signal }) => fetchImpl(url, { headers, signal, redirect: "manual" });
|
|
1087
|
+
}
|
|
1088
|
+
async function selectTransport(opts, url) {
|
|
1089
|
+
if (opts.fetchImpl) {
|
|
1090
|
+
return fetchTransport(opts.fetchImpl);
|
|
1091
|
+
}
|
|
1092
|
+
const modules = await loadNodeHttpModules();
|
|
1093
|
+
if (!modules) {
|
|
1094
|
+
const platformFetch = globalThis.fetch;
|
|
1095
|
+
if (typeof platformFetch === "function") {
|
|
1096
|
+
return fetchTransport(platformFetch);
|
|
1097
|
+
}
|
|
1098
|
+
throw new SsrfError("No fetch implementation available to load OpenAPI spec from URL", { url });
|
|
1099
|
+
}
|
|
1100
|
+
return nodePinnedTransport(modules);
|
|
1101
|
+
}
|
|
1102
|
+
async function safeFetch(url, opts) {
|
|
1103
|
+
const { headers, timeoutMs = 3e4, followRedirects = true, maxRedirects = 5, ssrf, lookup } = opts;
|
|
1104
|
+
const transport = await selectTransport(opts, url);
|
|
1105
|
+
let current = url;
|
|
1106
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1107
|
+
const pinned = await assertUrlSafe(current, ssrf, lookup);
|
|
1108
|
+
const controller = new AbortController();
|
|
1109
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1110
|
+
let response;
|
|
1111
|
+
try {
|
|
1112
|
+
response = await transport(current, { headers, signal: controller.signal, pinned, maxBytes: opts.maxResponseBytes });
|
|
1113
|
+
} finally {
|
|
1114
|
+
clearTimeout(timer);
|
|
1115
|
+
}
|
|
1116
|
+
const status = typeof response.status === "number" ? response.status : 0;
|
|
1117
|
+
const isRedirect = status >= 300 && status < 400 && status !== 304;
|
|
1118
|
+
if (!isRedirect || !followRedirects) {
|
|
1119
|
+
return response;
|
|
1120
|
+
}
|
|
1121
|
+
const location = response.headers?.get?.("location") ?? void 0;
|
|
1122
|
+
if (!location) {
|
|
1123
|
+
return response;
|
|
1124
|
+
}
|
|
1125
|
+
current = new URL(location, current).toString();
|
|
1126
|
+
}
|
|
1127
|
+
throw new SsrfError(`Too many redirects while loading OpenAPI spec (max ${maxRedirects})`, { url });
|
|
1128
|
+
}
|
|
1129
|
+
|
|
847
1130
|
// src/generator.ts
|
|
848
1131
|
var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
849
1132
|
document;
|
|
@@ -869,14 +1152,12 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
869
1152
|
*/
|
|
870
1153
|
static async fromURL(url, options = {}) {
|
|
871
1154
|
try {
|
|
872
|
-
const
|
|
873
|
-
const timeout = setTimeout(() => controller.abort(), options.timeout ?? 3e4);
|
|
874
|
-
const response = await fetch(url, {
|
|
1155
|
+
const response = await safeFetch(url, {
|
|
875
1156
|
headers: options.headers,
|
|
876
|
-
|
|
877
|
-
|
|
1157
|
+
timeoutMs: options.timeout ?? 3e4,
|
|
1158
|
+
followRedirects: options.followRedirects ?? true,
|
|
1159
|
+
ssrf: normalizeSsrfOptions(options.refResolution)
|
|
878
1160
|
});
|
|
879
|
-
clearTimeout(timeout);
|
|
880
1161
|
if (!response.ok) {
|
|
881
1162
|
throw new LoadError(`Failed to fetch OpenAPI spec from URL: ${response.status} ${response.statusText}`, {
|
|
882
1163
|
url,
|
|
@@ -967,87 +1248,11 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
967
1248
|
const validator = new Validator();
|
|
968
1249
|
return validator.validate(this.document);
|
|
969
1250
|
}
|
|
970
|
-
|
|
971
|
-
|
|
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
|
-
}
|
|
1251
|
+
// NOTE: internal/private-address blocking + IPv4-mapped-IPv6 decoding now live
|
|
1252
|
+
// in `ssrf.ts` (`isBlockedHostname` / `isBlockedAddress` / `decodeIpv4MappedIpv6`),
|
|
1253
|
+
// shared by the spec-URL fetch (`fromURL`) and the `$ref` resolver below, and
|
|
1254
|
+
// augmented there with DNS resolution (closing the DNS-name-to-internal bypass)
|
|
1255
|
+
// and per-hop redirect re-validation (`safeFetch`).
|
|
1051
1256
|
/**
|
|
1052
1257
|
* Build $RefParser options based on refResolution configuration.
|
|
1053
1258
|
* Defaults: allow http/https, block file://, block internal IPs.
|
|
@@ -1074,13 +1279,17 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
1074
1279
|
const hostAllowSet = new Set(refOpts.allowedHosts);
|
|
1075
1280
|
resolveConfig["http"] = {
|
|
1076
1281
|
// SECURITY: never auto-follow HTTP redirects when resolving external
|
|
1077
|
-
// `$ref`s. `canRead`
|
|
1078
|
-
//
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
//
|
|
1282
|
+
// `$ref`s. `canRead` validates only the INITIAL URL; the resolver's
|
|
1283
|
+
// default redirect-following (up to 5 hops) re-fetches the `Location`
|
|
1284
|
+
// target WITHOUT re-invoking `canRead`, so an allowlisted host could
|
|
1285
|
+
// 302 → `http://169.254.169.254/...` and smuggle a blocked target past
|
|
1286
|
+
// the allow/deny lists. `redirects: 0` refuses the first redirect, and
|
|
1287
|
+
// our custom `read` (below) additionally refuses redirects itself.
|
|
1083
1288
|
redirects: 0,
|
|
1289
|
+
// Synchronous gate: protocol, host allow-list, and literal/known
|
|
1290
|
+
// internal hosts. DNS names that *resolve* to internal addresses pass
|
|
1291
|
+
// here (canRead cannot be async) and are caught in `read` via DNS
|
|
1292
|
+
// resolution — closing the `127.0.0.1.nip.io` bypass for `$ref`s too.
|
|
1084
1293
|
canRead: (file) => {
|
|
1085
1294
|
try {
|
|
1086
1295
|
const parsed = new URL(file.url);
|
|
@@ -1091,13 +1300,31 @@ var OpenAPIToolGenerator = class _OpenAPIToolGenerator {
|
|
|
1091
1300
|
if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
|
|
1092
1301
|
return false;
|
|
1093
1302
|
}
|
|
1094
|
-
if (
|
|
1303
|
+
if (isBlockedHostname(parsed.hostname, refOpts)) {
|
|
1095
1304
|
return false;
|
|
1096
1305
|
}
|
|
1097
1306
|
return true;
|
|
1098
1307
|
} catch {
|
|
1099
1308
|
return false;
|
|
1100
1309
|
}
|
|
1310
|
+
},
|
|
1311
|
+
// SSRF-safe fetch: resolves DNS and rejects names that map to internal
|
|
1312
|
+
// addresses, and refuses redirects. NOTE: deliberately does NOT forward
|
|
1313
|
+
// `this.options.headers` (the spec-load credentials) to third-party
|
|
1314
|
+
// `$ref` hosts — that would leak the spec's auth token cross-origin.
|
|
1315
|
+
read: async (file) => {
|
|
1316
|
+
const response = await safeFetch(file.url, {
|
|
1317
|
+
timeoutMs: this.options.timeout,
|
|
1318
|
+
followRedirects: false,
|
|
1319
|
+
ssrf: refOpts
|
|
1320
|
+
});
|
|
1321
|
+
if (!response.ok) {
|
|
1322
|
+
throw new LoadError(
|
|
1323
|
+
`Failed to resolve external $ref "${file.url}": ${response.status} ${response.statusText}`,
|
|
1324
|
+
{ url: file.url, status: response.status }
|
|
1325
|
+
);
|
|
1326
|
+
}
|
|
1327
|
+
return response.text();
|
|
1101
1328
|
}
|
|
1102
1329
|
};
|
|
1103
1330
|
} else {
|
|
@@ -1910,6 +2137,7 @@ function createSecurityContext(auth) {
|
|
|
1910
2137
|
}
|
|
1911
2138
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1912
2139
|
0 && (module.exports = {
|
|
2140
|
+
BLOCKED_HOSTNAMES,
|
|
1913
2141
|
BUILTIN_FORMAT_RESOLVERS,
|
|
1914
2142
|
GenerationError,
|
|
1915
2143
|
LoadError,
|
|
@@ -1921,10 +2149,18 @@ function createSecurityContext(auth) {
|
|
|
1921
2149
|
SchemaBuilder,
|
|
1922
2150
|
SchemaError,
|
|
1923
2151
|
SecurityResolver,
|
|
2152
|
+
SsrfError,
|
|
1924
2153
|
ValidationError,
|
|
1925
2154
|
Validator,
|
|
2155
|
+
assertUrlSafe,
|
|
1926
2156
|
createSecurityContext,
|
|
2157
|
+
decodeIpv4MappedIpv6,
|
|
2158
|
+
defaultLookup,
|
|
2159
|
+
isBlockedAddress,
|
|
2160
|
+
isBlockedHostname,
|
|
1927
2161
|
isReferenceObject,
|
|
2162
|
+
normalizeSsrfOptions,
|
|
1928
2163
|
resolveSchemaFormats,
|
|
2164
|
+
safeFetch,
|
|
1929
2165
|
toJsonSchema
|
|
1930
2166
|
});
|
package/package.json
CHANGED
package/ssrf.d.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF protection for spec loading and external `$ref` resolution.
|
|
3
|
+
*
|
|
4
|
+
* The generator fetches two kinds of attacker-influenceable URLs:
|
|
5
|
+
* 1. the OpenAPI spec itself (`fromURL`), and
|
|
6
|
+
* 2. external `$ref` targets during dereferencing.
|
|
7
|
+
*
|
|
8
|
+
* A hostname-string denylist (the pre-2.5 approach) is bypassable, as reported
|
|
9
|
+
* in GHSA-65h7-9wrw-629c:
|
|
10
|
+
* - DNS names that resolve to internal IPs, e.g. `http://127.0.0.1.nip.io/`
|
|
11
|
+
* — the literal-string `127.0.0.1` patterns never match, yet the name
|
|
12
|
+
* resolves to loopback;
|
|
13
|
+
* - IPv4-mapped IPv6 forms (handled since 2.4 via {@link decodeIpv4MappedIpv6});
|
|
14
|
+
* - redirects from an allowed host to an internal one.
|
|
15
|
+
*
|
|
16
|
+
* This module validates the **resolved IP**, not just the hostname string:
|
|
17
|
+
* - parses/normalizes IPv4 (incl. numeric/hex/octal forms canonicalized by
|
|
18
|
+
* `new URL()`) and IPv4-mapped IPv6 before range checks;
|
|
19
|
+
* - blocks loopback / private (RFC 1918) / CGNAT (RFC 6598) / link-local /
|
|
20
|
+
* multicast / unspecified / reserved ranges and cloud-metadata endpoints;
|
|
21
|
+
* - resolves the hostname (Node, via `node:dns`) and rejects if **any**
|
|
22
|
+
* resolved address is internal — closing the DNS-name-to-internal bypass;
|
|
23
|
+
* - **pins the connection to the validated IP**: on Node, {@link safeFetch}
|
|
24
|
+
* connects through `node:http`/`node:https` with a custom `lookup` that
|
|
25
|
+
* returns the exact address {@link assertUrlSafe} just validated, so the
|
|
26
|
+
* guard and the socket share one DNS resolution. This closes the
|
|
27
|
+
* DNS-rebinding TOCTOU where the client would otherwise re-resolve the
|
|
28
|
+
* hostname at connect time and reach a different (internal) address. The
|
|
29
|
+
* original hostname is preserved for the `Host` header and TLS SNI;
|
|
30
|
+
* - re-validates every redirect hop ({@link safeFetch}) instead of letting the
|
|
31
|
+
* HTTP client follow 3xx blindly.
|
|
32
|
+
*
|
|
33
|
+
* Node-aware: DNS resolution lazily imports `node:dns` and IP pinning lazily
|
|
34
|
+
* imports `node:http`/`node:https`. On runtimes without them (Web/edge) the
|
|
35
|
+
* literal-address checks still apply and the fetch falls back to the platform
|
|
36
|
+
* `fetch` (best-effort, without connection pinning) — combine with an
|
|
37
|
+
* `allowedHosts` allow-list and network egress controls there.
|
|
38
|
+
*
|
|
39
|
+
* Fails closed: a genuine resolver error rejects the URL rather than proceeding
|
|
40
|
+
* unvalidated; only a runtime that has no resolver at all (edge) skips the DNS
|
|
41
|
+
* step ({@link SsrfResolverUnavailableError}).
|
|
42
|
+
*/
|
|
43
|
+
import type { RefResolutionOptions } from './types';
|
|
44
|
+
/** The subset of {@link RefResolutionOptions} relevant to address blocking. */
|
|
45
|
+
export interface ResolvedSsrfOptions {
|
|
46
|
+
allowedHosts: string[];
|
|
47
|
+
blockedHosts: string[];
|
|
48
|
+
allowInternalIPs: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** Resolved DNS address shape (subset of Node's `dns.LookupAddress`). */
|
|
51
|
+
export interface ResolvedAddress {
|
|
52
|
+
address: string;
|
|
53
|
+
family: number;
|
|
54
|
+
}
|
|
55
|
+
/** Hostname → resolved addresses. Injectable for testing. */
|
|
56
|
+
export type SsrfHostLookup = (hostname: string) => Promise<ResolvedAddress[]>;
|
|
57
|
+
/**
|
|
58
|
+
* Non-IP hostnames that map to internal targets, so the IP-range checks alone
|
|
59
|
+
* would miss them when DNS resolution is unavailable. DNS resolution (when
|
|
60
|
+
* available) also catches these; this set is defense-in-depth.
|
|
61
|
+
*/
|
|
62
|
+
export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
|
|
63
|
+
/** Normalize a (possibly undefined) {@link RefResolutionOptions} to the SSRF subset. */
|
|
64
|
+
export declare function normalizeSsrfOptions(refResolution?: RefResolutionOptions): ResolvedSsrfOptions;
|
|
65
|
+
/**
|
|
66
|
+
* Decode an IPv4-mapped IPv6 host (`::ffff:169.254.169.254` or its hex form
|
|
67
|
+
* `::ffff:a9fe:a9fe`, optionally bracketed) to its embedded dotted-quad IPv4, or
|
|
68
|
+
* `null` if the host isn't IPv4-mapped. `new URL().hostname` normalizes
|
|
69
|
+
* `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, which the plain
|
|
70
|
+
* dotted-quad range checks would otherwise miss.
|
|
71
|
+
*/
|
|
72
|
+
export declare function decodeIpv4MappedIpv6(hostname: string): string | null;
|
|
73
|
+
/**
|
|
74
|
+
* Predicate: is `host` (an IP literal — dotted-quad IPv4, bracketed/zoned IPv6,
|
|
75
|
+
* or IPv4-mapped IPv6) in a blocked, non-public range? Returns `false` for
|
|
76
|
+
* non-IP-literal hostnames (use DNS resolution for those).
|
|
77
|
+
*/
|
|
78
|
+
export declare function isBlockedAddress(host: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Synchronous host check used by the `$RefParser` `canRead` filter (which cannot
|
|
81
|
+
* be async). Blocks known internal hostnames, explicit `blockedHosts`, and
|
|
82
|
+
* literal internal IPs (incl. IPv4-mapped IPv6). DNS names that *resolve* to
|
|
83
|
+
* internal addresses are caught later, asynchronously, in {@link safeFetch}.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isBlockedHostname(hostname: string, ssrf: ResolvedSsrfOptions): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Signals that no DNS resolver is available on the current runtime (e.g. a
|
|
88
|
+
* Web/edge isolate without `node:dns`). {@link assertUrlSafe} treats this as
|
|
89
|
+
* "cannot resolve, cannot pin" and proceeds on literal-address checks only,
|
|
90
|
+
* whereas a genuine resolver failure fails closed.
|
|
91
|
+
*/
|
|
92
|
+
export declare class SsrfResolverUnavailableError extends Error {
|
|
93
|
+
}
|
|
94
|
+
/** Default DNS resolver: lazily loads `node:dns`; signals unavailability off-Node. */
|
|
95
|
+
export declare const defaultLookup: SsrfHostLookup;
|
|
96
|
+
/**
|
|
97
|
+
* Validate that `url` is safe to fetch (spec URL or `$ref` target), throwing
|
|
98
|
+
* {@link SsrfError} if not. Enforces http/https, the `allowedHosts` allow-list,
|
|
99
|
+
* the internal-address denylist, and — for DNS names — resolves and rejects if
|
|
100
|
+
* any resolved address is internal.
|
|
101
|
+
*
|
|
102
|
+
* Returns the validated resolved addresses so the caller can **pin** the
|
|
103
|
+
* connection to them ({@link safeFetch}), guaranteeing the socket connects to
|
|
104
|
+
* the exact IP that was validated rather than re-resolving the hostname. An
|
|
105
|
+
* empty array means "no pinning needed" (a literal IP, `allowInternalIPs`, or a
|
|
106
|
+
* runtime without a resolver).
|
|
107
|
+
*
|
|
108
|
+
* Fails closed: a genuine resolver error (or a name that resolves to no
|
|
109
|
+
* address) rejects the URL. Only {@link SsrfResolverUnavailableError} — no
|
|
110
|
+
* resolver on this runtime — is treated as best-effort and returns `[]`.
|
|
111
|
+
*/
|
|
112
|
+
export declare function assertUrlSafe(url: string, ssrf: ResolvedSsrfOptions, lookup?: SsrfHostLookup): Promise<ResolvedAddress[]>;
|
|
113
|
+
/** Options for {@link safeFetch}. */
|
|
114
|
+
export interface SafeFetchOptions {
|
|
115
|
+
headers?: Record<string, string>;
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
/** Follow 3xx redirects (re-validating each hop). @default true */
|
|
118
|
+
followRedirects?: boolean;
|
|
119
|
+
/** Max redirect hops before failing. @default 5 */
|
|
120
|
+
maxRedirects?: number;
|
|
121
|
+
/** Max response body bytes before the request is aborted (Node transport). @default 10 MiB */
|
|
122
|
+
maxResponseBytes?: number;
|
|
123
|
+
ssrf: ResolvedSsrfOptions;
|
|
124
|
+
/** Injectable DNS resolver (tests). */
|
|
125
|
+
lookup?: SsrfHostLookup;
|
|
126
|
+
/**
|
|
127
|
+
* Injectable fetch implementation (tests / custom runtimes). Providing this
|
|
128
|
+
* bypasses the Node connection-pinning transport, so the implementation is
|
|
129
|
+
* responsible for connecting to the validated address itself.
|
|
130
|
+
*/
|
|
131
|
+
fetchImpl?: typeof fetch;
|
|
132
|
+
}
|
|
133
|
+
/** Lazily-loaded `node:http`/`node:https` modules. */
|
|
134
|
+
export interface NodeHttpModules {
|
|
135
|
+
http: typeof import('node:http');
|
|
136
|
+
https: typeof import('node:https');
|
|
137
|
+
}
|
|
138
|
+
/** A transport that issues one GET and returns the raw (unfollowed) response. */
|
|
139
|
+
type SsrfTransport = (url: string, request: {
|
|
140
|
+
headers?: Record<string, string>;
|
|
141
|
+
signal: AbortSignal;
|
|
142
|
+
pinned: ResolvedAddress[];
|
|
143
|
+
maxBytes?: number;
|
|
144
|
+
}) => Promise<Response>;
|
|
145
|
+
/** Select the Node transport module for a URL protocol. Exported for tests. */
|
|
146
|
+
export declare function pickHttpModule(protocol: string, modules: NodeHttpModules): NodeHttpModules['http'] | NodeHttpModules['https'];
|
|
147
|
+
/**
|
|
148
|
+
* A `node:net` lookup that always returns the pre-validated addresses, ignoring
|
|
149
|
+
* the queried hostname — pinning the socket to the exact IP {@link assertUrlSafe}
|
|
150
|
+
* validated so the connection cannot be re-resolved to a different (internal)
|
|
151
|
+
* address. Exported for tests.
|
|
152
|
+
*/
|
|
153
|
+
export declare function makePinnedLookup(pinned: ResolvedAddress[]): (_hostname: string, options: unknown, callback?: unknown) => void;
|
|
154
|
+
/**
|
|
155
|
+
* Node transport that pins the connection to the validated address(es) via a
|
|
156
|
+
* custom `lookup`, preserving the original hostname for the `Host` header and
|
|
157
|
+
* TLS SNI. Manual redirects only (no `lookup` re-resolution between hops).
|
|
158
|
+
* Exported for tests.
|
|
159
|
+
*/
|
|
160
|
+
export declare function nodePinnedTransport(modules: NodeHttpModules): SsrfTransport;
|
|
161
|
+
/**
|
|
162
|
+
* SSRF-safe `fetch`: validates the initial URL and **every redirect hop** with
|
|
163
|
+
* {@link assertUrlSafe} before issuing the request, then **pins** the connection
|
|
164
|
+
* to the validated IP (on Node) so the socket can't be rebound to an internal
|
|
165
|
+
* address. Uses manual redirect handling so a 3xx to an internal target can't be
|
|
166
|
+
* followed without re-validation. Returns the final {@link Response} (the caller
|
|
167
|
+
* checks `response.ok` / reads the body).
|
|
168
|
+
*/
|
|
169
|
+
export declare function safeFetch(url: string, opts: SafeFetchOptions): Promise<Response>;
|
|
170
|
+
export {};
|
package/types.d.ts
CHANGED
|
@@ -327,9 +327,15 @@ export interface ServerInfo {
|
|
|
327
327
|
variables?: Record<string, ServerVariableObject>;
|
|
328
328
|
}
|
|
329
329
|
/**
|
|
330
|
-
* Controls how external
|
|
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;
|