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