@spfn/core 0.2.0-beta.9 → 0.3.0-beta.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.
Files changed (95) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +444 -305
  3. package/dist/authz/index.d.ts +34 -0
  4. package/dist/authz/index.js +810 -0
  5. package/dist/authz/index.js.map +1 -0
  6. package/dist/{boss-DI1r4kTS.d.ts → boss-D16fO2oG.d.ts} +41 -1
  7. package/dist/cache/index.js +42 -30
  8. package/dist/cache/index.js.map +1 -1
  9. package/dist/codegen/index.d.ts +121 -13
  10. package/dist/codegen/index.js +212 -15
  11. package/dist/codegen/index.js.map +1 -1
  12. package/dist/config/index.d.ts +615 -6
  13. package/dist/config/index.js +124 -5
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/contract/index.d.ts +220 -0
  16. package/dist/contract/index.js +558 -0
  17. package/dist/contract/index.js.map +1 -0
  18. package/dist/db/index.d.ts +528 -85
  19. package/dist/db/index.js +831 -122
  20. package/dist/db/index.js.map +1 -1
  21. package/dist/define-middleware-DfDP39Nq.d.ts +167 -0
  22. package/dist/env/index.d.ts +26 -2
  23. package/dist/env/index.js +15 -5
  24. package/dist/env/index.js.map +1 -1
  25. package/dist/env/loader.d.ts +26 -19
  26. package/dist/env/loader.js +32 -25
  27. package/dist/env/loader.js.map +1 -1
  28. package/dist/errors/index.d.ts +10 -0
  29. package/dist/errors/index.js +418 -5
  30. package/dist/errors/index.js.map +1 -1
  31. package/dist/event/index.d.ts +33 -3
  32. package/dist/event/index.js +24 -3
  33. package/dist/event/index.js.map +1 -1
  34. package/dist/event/sse/client.d.ts +42 -3
  35. package/dist/event/sse/client.js +128 -45
  36. package/dist/event/sse/client.js.map +1 -1
  37. package/dist/event/sse/index.d.ts +12 -5
  38. package/dist/event/sse/index.js +280 -32
  39. package/dist/event/sse/index.js.map +1 -1
  40. package/dist/event/ws/client.d.ts +59 -0
  41. package/dist/event/ws/client.js +273 -0
  42. package/dist/event/ws/client.js.map +1 -0
  43. package/dist/event/ws/index.d.ts +94 -0
  44. package/dist/event/ws/index.js +272 -0
  45. package/dist/event/ws/index.js.map +1 -0
  46. package/dist/job/index.d.ts +2 -2
  47. package/dist/job/index.js +155 -42
  48. package/dist/job/index.js.map +1 -1
  49. package/dist/logger/index.d.ts +5 -0
  50. package/dist/logger/index.js +14 -0
  51. package/dist/logger/index.js.map +1 -1
  52. package/dist/middleware/index.d.ts +347 -9
  53. package/dist/middleware/index.js +1462 -15
  54. package/dist/middleware/index.js.map +1 -1
  55. package/dist/nextjs/index.d.ts +2 -2
  56. package/dist/nextjs/index.js +42 -28
  57. package/dist/nextjs/index.js.map +1 -1
  58. package/dist/nextjs/server.d.ts +35 -51
  59. package/dist/nextjs/server.js +126 -60
  60. package/dist/nextjs/server.js.map +1 -1
  61. package/dist/ops/index.d.ts +107 -0
  62. package/dist/ops/index.js +476 -0
  63. package/dist/ops/index.js.map +1 -0
  64. package/dist/route/index.d.ts +8 -694
  65. package/dist/route/index.js +111 -22
  66. package/dist/route/index.js.map +1 -1
  67. package/dist/router-ukNdAZcN.d.ts +676 -0
  68. package/dist/security/index.d.ts +83 -0
  69. package/dist/security/index.js +173 -0
  70. package/dist/security/index.js.map +1 -0
  71. package/dist/server/index.d.ts +491 -22
  72. package/dist/server/index.js +1887 -308
  73. package/dist/server/index.js.map +1 -1
  74. package/dist/token-manager-BT5EnUAR.d.ts +278 -0
  75. package/dist/types-2AbaW4Ie.d.ts +205 -0
  76. package/dist/{types-BOPTApC2.d.ts → types-9oszaJqp.d.ts} +7 -2
  77. package/dist/types-Bvvig_tT.d.ts +115 -0
  78. package/dist/types-ZQODsBft.d.ts +282 -0
  79. package/package.json +244 -208
  80. package/dist/router-Di7ENoah.d.ts +0 -151
  81. package/dist/types-B-e_f2dQ.d.ts +0 -121
  82. package/docs/cache.md +0 -133
  83. package/docs/codegen.md +0 -74
  84. package/docs/database.md +0 -346
  85. package/docs/entity.md +0 -539
  86. package/docs/env.md +0 -477
  87. package/docs/errors.md +0 -319
  88. package/docs/event.md +0 -116
  89. package/docs/job.md +0 -131
  90. package/docs/logger.md +0 -108
  91. package/docs/middleware.md +0 -337
  92. package/docs/nextjs.md +0 -241
  93. package/docs/repository.md +0 -496
  94. package/docs/route.md +0 -497
  95. package/docs/server.md +0 -307
@@ -0,0 +1,83 @@
1
+ import { fetch, Agent } from 'undici';
2
+
3
+ /**
4
+ * @spfn/core - SSRF-safe outbound fetch
5
+ *
6
+ * A drop-in `fetch` for calling URLs that may be influenced by user input
7
+ * (webhooks, image fetchers, callback URLs). It blocks requests to private and
8
+ * reserved IP ranges — including the cloud metadata address (169.254.169.254) —
9
+ * and defeats DNS rebinding by resolving the hostname, validating every returned
10
+ * address, and pinning the connection to a validated IP via a custom undici
11
+ * `lookup`. Redirects go through the same dispatcher, so each hop is re-validated.
12
+ *
13
+ * A plain string allowlist cannot stop SSRF on its own: an attacker-controlled
14
+ * hostname can resolve to a private IP (DNS rebinding). The pinning is the point.
15
+ */
16
+
17
+ /** Thrown when a request target is blocked by the SSRF policy. */
18
+ declare class SsrfBlockedError extends Error {
19
+ constructor(message: string);
20
+ }
21
+ interface SafeFetchPolicy {
22
+ /** URL schemes allowed. @default ['http:', 'https:'] */
23
+ allowedProtocols?: string[];
24
+ /**
25
+ * Reject targets that resolve to a private or reserved IP range (loopback,
26
+ * link-local/metadata, RFC1918, ULA, multicast, …). @default true
27
+ */
28
+ blockPrivateIps?: boolean;
29
+ /**
30
+ * Exact hostname allowlist (case-insensitive). When set, only these hosts
31
+ * are reachable — the strongest control for a known set of upstreams.
32
+ * Enforced on every redirect hop, not just the first URL.
33
+ */
34
+ allowHosts?: string[];
35
+ /** Max redirects to follow, each re-validated. @default 5 */
36
+ maxRedirects?: number;
37
+ }
38
+ /**
39
+ * Whether an IP literal falls in a private or reserved range. A non-IP input is
40
+ * treated as unsafe (`true`) — callers pass resolved addresses, never hostnames.
41
+ */
42
+ declare function isPrivateOrReservedIp(ip: string): boolean;
43
+ /**
44
+ * Validate a URL for SSRF without making the request: runs the sync checks and,
45
+ * for hostnames, resolves DNS and rejects if any address is private/reserved.
46
+ *
47
+ * Use this to guard a URL handed to code you do not control (so you cannot pin
48
+ * the connection). It cannot prevent rebinding between this check and that
49
+ * code's own connection — for requests you make yourself, use {@link safeFetch}.
50
+ */
51
+ declare function assertSafeUrl(rawUrl: string, policy?: SafeFetchPolicy): Promise<void>;
52
+ type FetchInput = Parameters<typeof fetch>[0];
53
+ type FetchInit = Parameters<typeof fetch>[1];
54
+ type FetchReturn = ReturnType<typeof fetch>;
55
+ type SafeFetchFn = ((input: FetchInput, init?: FetchInit) => FetchReturn) & {
56
+ _dispatcher: Agent;
57
+ };
58
+ /**
59
+ * Build an SSRF-safe fetch bound to a policy. Reuse the returned function (it
60
+ * owns a pooled dispatcher) rather than calling this per request.
61
+ *
62
+ * Redirects are followed manually so EVERY hop is validated — including a hop
63
+ * whose target is a bare IP literal, which undici would otherwise connect to
64
+ * directly without invoking the pinning lookup. The original method/body and
65
+ * headers are NOT replayed across a redirect: the next hop may be attacker-
66
+ * chosen, so forwarding the payload or auth headers would leak them.
67
+ */
68
+ declare function createSafeFetch(policy?: SafeFetchPolicy): SafeFetchFn;
69
+ /**
70
+ * Replace the default policy used by {@link safeFetch}. Called by the server at
71
+ * boot from `defineServerConfig().outboundFetch(...)`. Passing undefined resets
72
+ * to the secure defaults (private IPs blocked, http/https only).
73
+ */
74
+ declare function setDefaultSafeFetchPolicy(policy?: SafeFetchPolicy): void;
75
+ /** The effective default policy (secure defaults merged with any configured overrides). */
76
+ declare function getDefaultSafeFetchPolicy(): SafeFetchPolicy;
77
+ /**
78
+ * SSRF-safe `fetch` using the configured default policy. Drop-in replacement for
79
+ * `fetch` when the URL may be influenced by user input.
80
+ */
81
+ declare function safeFetch(input: FetchInput, init?: FetchInit): FetchReturn;
82
+
83
+ export { type SafeFetchPolicy, SsrfBlockedError, assertSafeUrl, createSafeFetch, getDefaultSafeFetchPolicy, isPrivateOrReservedIp, safeFetch, setDefaultSafeFetchPolicy };
@@ -0,0 +1,173 @@
1
+ import { promises } from 'dns';
2
+ import { BlockList, isIP } from 'net';
3
+ import { Agent, fetch } from 'undici';
4
+
5
+ // src/security/safe-fetch.ts
6
+ var SsrfBlockedError = class extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "SsrfBlockedError";
10
+ }
11
+ };
12
+ var DEFAULT_MAX_REDIRECTS = 5;
13
+ var DEFAULTS = {
14
+ allowedProtocols: ["http:", "https:"],
15
+ blockPrivateIps: true
16
+ };
17
+ var blockedRanges = (() => {
18
+ const list = new BlockList();
19
+ list.addSubnet("0.0.0.0", 8, "ipv4");
20
+ list.addSubnet("10.0.0.0", 8, "ipv4");
21
+ list.addSubnet("100.64.0.0", 10, "ipv4");
22
+ list.addSubnet("127.0.0.0", 8, "ipv4");
23
+ list.addSubnet("169.254.0.0", 16, "ipv4");
24
+ list.addSubnet("172.16.0.0", 12, "ipv4");
25
+ list.addSubnet("192.0.0.0", 24, "ipv4");
26
+ list.addSubnet("192.0.2.0", 24, "ipv4");
27
+ list.addSubnet("192.168.0.0", 16, "ipv4");
28
+ list.addSubnet("198.18.0.0", 15, "ipv4");
29
+ list.addSubnet("198.51.100.0", 24, "ipv4");
30
+ list.addSubnet("203.0.113.0", 24, "ipv4");
31
+ list.addSubnet("224.0.0.0", 4, "ipv4");
32
+ list.addSubnet("240.0.0.0", 4, "ipv4");
33
+ list.addAddress("::1", "ipv6");
34
+ list.addAddress("::", "ipv6");
35
+ list.addSubnet("fc00::", 7, "ipv6");
36
+ list.addSubnet("fe80::", 10, "ipv6");
37
+ list.addSubnet("ff00::", 8, "ipv6");
38
+ list.addSubnet("2001:db8::", 32, "ipv6");
39
+ list.addSubnet("64:ff9b::", 96, "ipv6");
40
+ list.addSubnet("2002::", 16, "ipv6");
41
+ list.addSubnet("192.88.99.0", 24, "ipv4");
42
+ return list;
43
+ })();
44
+ function isPrivateOrReservedIp(ip) {
45
+ const family = isIP(ip);
46
+ if (family === 4) {
47
+ return blockedRanges.check(ip, "ipv4");
48
+ }
49
+ if (family === 6) {
50
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip);
51
+ if (mapped) {
52
+ return blockedRanges.check(mapped[1], "ipv4");
53
+ }
54
+ return blockedRanges.check(ip, "ipv6");
55
+ }
56
+ return true;
57
+ }
58
+ function stripBrackets(hostname) {
59
+ return hostname.replace(/^\[/, "").replace(/\]$/, "");
60
+ }
61
+ function assertUrlAllowed(rawUrl, policy) {
62
+ let url;
63
+ try {
64
+ url = new URL(rawUrl);
65
+ } catch {
66
+ throw new SsrfBlockedError(`Invalid URL: ${rawUrl}`);
67
+ }
68
+ const protocols = policy.allowedProtocols ?? DEFAULTS.allowedProtocols;
69
+ if (!protocols.includes(url.protocol)) {
70
+ throw new SsrfBlockedError(`Protocol not allowed: ${url.protocol}`);
71
+ }
72
+ const host = stripBrackets(url.hostname);
73
+ if (policy.allowHosts) {
74
+ const allowed = policy.allowHosts.some((h) => h.toLowerCase() === host.toLowerCase());
75
+ if (!allowed) {
76
+ throw new SsrfBlockedError(`Host not in allowlist: ${host}`);
77
+ }
78
+ }
79
+ if (policy.blockPrivateIps !== false && isIP(host) && isPrivateOrReservedIp(host)) {
80
+ throw new SsrfBlockedError(`Blocked address: ${host}`);
81
+ }
82
+ return url;
83
+ }
84
+ async function assertSafeUrl(rawUrl, policy) {
85
+ const merged = { ...getDefaultSafeFetchPolicy(), ...policy };
86
+ const url = assertUrlAllowed(rawUrl, merged);
87
+ const host = stripBrackets(url.hostname);
88
+ if (isIP(host) || merged.blockPrivateIps === false) {
89
+ return;
90
+ }
91
+ const addresses = await promises.lookup(host, { all: true });
92
+ for (const { address } of addresses) {
93
+ if (isPrivateOrReservedIp(address)) {
94
+ throw new SsrfBlockedError(`Host resolves to a blocked address: ${host} \u2192 ${address}`);
95
+ }
96
+ }
97
+ }
98
+ function pinnedLookup(policy) {
99
+ return (hostname, options, callback) => {
100
+ const family = typeof options === "object" && typeof options.family === "number" ? options.family : 0;
101
+ const wantsAll = typeof options === "object" && options.all === true;
102
+ promises.lookup(hostname, { all: true, verbatim: true, family }).then(
103
+ (addresses) => {
104
+ const safe = policy.blockPrivateIps === false ? addresses : addresses.filter((a) => !isPrivateOrReservedIp(a.address));
105
+ if (safe.length === 0) {
106
+ callback(new SsrfBlockedError(`Host resolves only to blocked addresses: ${hostname}`), "", 0);
107
+ return;
108
+ }
109
+ if (wantsAll) {
110
+ callback(null, safe);
111
+ return;
112
+ }
113
+ callback(null, safe[0].address, safe[0].family);
114
+ },
115
+ (err) => callback(err, "", 0)
116
+ );
117
+ };
118
+ }
119
+ function urlOf(input) {
120
+ if (typeof input === "string") {
121
+ return input;
122
+ }
123
+ if (input instanceof URL) {
124
+ return input.href;
125
+ }
126
+ return input.url;
127
+ }
128
+ function createSafeFetch(policy = {}) {
129
+ const merged = { ...DEFAULTS, ...policy };
130
+ const maxRedirects = merged.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
131
+ const dispatcher = new Agent({ connect: { lookup: pinnedLookup(merged) } });
132
+ const run = async (input, init) => {
133
+ let url = urlOf(input);
134
+ let hopInit = init;
135
+ for (let hop = 0; ; hop++) {
136
+ assertUrlAllowed(url, merged);
137
+ const res = await fetch(url, { ...hopInit, dispatcher, redirect: "manual" });
138
+ const location = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
139
+ if (!location) {
140
+ return res;
141
+ }
142
+ await res.body?.cancel().catch(() => {
143
+ });
144
+ if (hop >= maxRedirects) {
145
+ throw new SsrfBlockedError(`Too many redirects (> ${maxRedirects})`);
146
+ }
147
+ url = new URL(location, url).href;
148
+ hopInit = { method: "GET" };
149
+ }
150
+ };
151
+ const fn = ((input, init) => run(input, init));
152
+ fn._dispatcher = dispatcher;
153
+ return fn;
154
+ }
155
+ var configuredPolicy = {};
156
+ var cachedDefaultFetch;
157
+ function setDefaultSafeFetchPolicy(policy) {
158
+ cachedDefaultFetch?._dispatcher.close().catch(() => {
159
+ });
160
+ configuredPolicy = policy ?? {};
161
+ cachedDefaultFetch = void 0;
162
+ }
163
+ function getDefaultSafeFetchPolicy() {
164
+ return { ...DEFAULTS, ...configuredPolicy };
165
+ }
166
+ function safeFetch(input, init) {
167
+ cachedDefaultFetch ??= createSafeFetch(getDefaultSafeFetchPolicy());
168
+ return cachedDefaultFetch(input, init);
169
+ }
170
+
171
+ export { SsrfBlockedError, assertSafeUrl, createSafeFetch, getDefaultSafeFetchPolicy, isPrivateOrReservedIp, safeFetch, setDefaultSafeFetchPolicy };
172
+ //# sourceMappingURL=index.js.map
173
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/security/safe-fetch.ts"],"names":["dnsPromises","undiciFetch"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @spfn/core - SSRF-safe outbound fetch\n *\n * A drop-in `fetch` for calling URLs that may be influenced by user input\n * (webhooks, image fetchers, callback URLs). It blocks requests to private and\n * reserved IP ranges — including the cloud metadata address (169.254.169.254) —\n * and defeats DNS rebinding by resolving the hostname, validating every returned\n * address, and pinning the connection to a validated IP via a custom undici\n * `lookup`. Redirects go through the same dispatcher, so each hop is re-validated.\n *\n * A plain string allowlist cannot stop SSRF on its own: an attacker-controlled\n * hostname can resolve to a private IP (DNS rebinding). The pinning is the point.\n */\n\nimport { promises as dnsPromises } from 'node:dns';\nimport { isIP, BlockList, type LookupFunction } from 'node:net';\nimport { fetch as undiciFetch, Agent } from 'undici';\n\n/** Thrown when a request target is blocked by the SSRF policy. */\nexport class SsrfBlockedError extends Error\n{\n constructor(message: string)\n {\n super(message);\n this.name = 'SsrfBlockedError';\n }\n}\n\nexport interface SafeFetchPolicy\n{\n /** URL schemes allowed. @default ['http:', 'https:'] */\n allowedProtocols?: string[];\n\n /**\n * Reject targets that resolve to a private or reserved IP range (loopback,\n * link-local/metadata, RFC1918, ULA, multicast, …). @default true\n */\n blockPrivateIps?: boolean;\n\n /**\n * Exact hostname allowlist (case-insensitive). When set, only these hosts\n * are reachable — the strongest control for a known set of upstreams.\n * Enforced on every redirect hop, not just the first URL.\n */\n allowHosts?: string[];\n\n /** Max redirects to follow, each re-validated. @default 5 */\n maxRedirects?: number;\n}\n\nconst DEFAULT_MAX_REDIRECTS = 5;\n\nconst DEFAULTS: Required<Pick<SafeFetchPolicy, 'allowedProtocols' | 'blockPrivateIps'>> = {\n allowedProtocols: ['http:', 'https:'],\n blockPrivateIps: true,\n};\n\n/**\n * Private/reserved IP ranges. Built once. `BlockList` handles CIDR membership\n * for both families; IPv4-mapped IPv6 is unwrapped and checked as IPv4 so a\n * mapped public address still resolves.\n */\nconst blockedRanges = (() =>\n{\n const list = new BlockList();\n\n // IPv4 — RFC 1918 + special-purpose / reserved\n list.addSubnet('0.0.0.0', 8, 'ipv4');\n list.addSubnet('10.0.0.0', 8, 'ipv4');\n list.addSubnet('100.64.0.0', 10, 'ipv4'); // CGNAT\n list.addSubnet('127.0.0.0', 8, 'ipv4'); // loopback\n list.addSubnet('169.254.0.0', 16, 'ipv4'); // link-local incl. cloud metadata\n list.addSubnet('172.16.0.0', 12, 'ipv4');\n list.addSubnet('192.0.0.0', 24, 'ipv4');\n list.addSubnet('192.0.2.0', 24, 'ipv4'); // TEST-NET-1\n list.addSubnet('192.168.0.0', 16, 'ipv4');\n list.addSubnet('198.18.0.0', 15, 'ipv4'); // benchmarking\n list.addSubnet('198.51.100.0', 24, 'ipv4'); // TEST-NET-2\n list.addSubnet('203.0.113.0', 24, 'ipv4'); // TEST-NET-3\n list.addSubnet('224.0.0.0', 4, 'ipv4'); // multicast\n list.addSubnet('240.0.0.0', 4, 'ipv4'); // reserved + broadcast\n\n // IPv6\n list.addAddress('::1', 'ipv6'); // loopback\n list.addAddress('::', 'ipv6'); // unspecified\n list.addSubnet('fc00::', 7, 'ipv6'); // unique local\n list.addSubnet('fe80::', 10, 'ipv6'); // link-local\n list.addSubnet('ff00::', 8, 'ipv6'); // multicast\n list.addSubnet('2001:db8::', 32, 'ipv6'); // documentation\n list.addSubnet('64:ff9b::', 96, 'ipv6'); // NAT64 well-known prefix (can wrap private v4)\n list.addSubnet('2002::', 16, 'ipv6'); // 6to4 (can wrap private v4)\n list.addSubnet('192.88.99.0', 24, 'ipv4'); // 6to4 anycast relay\n\n return list;\n})();\n\n/**\n * Whether an IP literal falls in a private or reserved range. A non-IP input is\n * treated as unsafe (`true`) — callers pass resolved addresses, never hostnames.\n */\nexport function isPrivateOrReservedIp(ip: string): boolean\n{\n const family = isIP(ip);\n\n if (family === 4)\n {\n return blockedRanges.check(ip, 'ipv4');\n }\n\n if (family === 6)\n {\n // IPv4-mapped (::ffff:a.b.c.d): judge by the embedded IPv4.\n const mapped = /^::ffff:(\\d{1,3}(?:\\.\\d{1,3}){3})$/i.exec(ip);\n if (mapped)\n {\n return blockedRanges.check(mapped[1], 'ipv4');\n }\n\n return blockedRanges.check(ip, 'ipv6');\n }\n\n return true;\n}\n\nfunction stripBrackets(hostname: string): string\n{\n return hostname.replace(/^\\[/, '').replace(/\\]$/, '');\n}\n\n/**\n * Synchronous, no-DNS checks: protocol, host allowlist, and — when the host is\n * an IP literal — the private-range check. Throws SsrfBlockedError on violation.\n */\nfunction assertUrlAllowed(rawUrl: string, policy: SafeFetchPolicy): URL\n{\n let url: URL;\n try\n {\n url = new URL(rawUrl);\n }\n catch\n {\n throw new SsrfBlockedError(`Invalid URL: ${rawUrl}`);\n }\n\n const protocols = policy.allowedProtocols ?? DEFAULTS.allowedProtocols;\n if (!protocols.includes(url.protocol))\n {\n throw new SsrfBlockedError(`Protocol not allowed: ${url.protocol}`);\n }\n\n const host = stripBrackets(url.hostname);\n\n if (policy.allowHosts)\n {\n const allowed = policy.allowHosts.some(h => h.toLowerCase() === host.toLowerCase());\n if (!allowed)\n {\n throw new SsrfBlockedError(`Host not in allowlist: ${host}`);\n }\n }\n\n if (policy.blockPrivateIps !== false && isIP(host) && isPrivateOrReservedIp(host))\n {\n throw new SsrfBlockedError(`Blocked address: ${host}`);\n }\n\n return url;\n}\n\n/**\n * Validate a URL for SSRF without making the request: runs the sync checks and,\n * for hostnames, resolves DNS and rejects if any address is private/reserved.\n *\n * Use this to guard a URL handed to code you do not control (so you cannot pin\n * the connection). It cannot prevent rebinding between this check and that\n * code's own connection — for requests you make yourself, use {@link safeFetch}.\n */\nexport async function assertSafeUrl(rawUrl: string, policy?: SafeFetchPolicy): Promise<void>\n{\n const merged = { ...getDefaultSafeFetchPolicy(), ...policy };\n const url = assertUrlAllowed(rawUrl, merged);\n const host = stripBrackets(url.hostname);\n\n // IP literals are fully judged by assertUrlAllowed; only hostnames need DNS.\n if (isIP(host) || merged.blockPrivateIps === false)\n {\n return;\n }\n\n const addresses = await dnsPromises.lookup(host, { all: true });\n for (const { address } of addresses)\n {\n if (isPrivateOrReservedIp(address))\n {\n throw new SsrfBlockedError(`Host resolves to a blocked address: ${host} → ${address}`);\n }\n }\n}\n\n/**\n * Custom DNS lookup for undici: resolve, drop private/reserved addresses, and\n * return only validated ones — so the connection is pinned to an address we\n * already checked (no rebinding window between check and connect).\n */\nfunction pinnedLookup(policy: SafeFetchPolicy): LookupFunction\n{\n return (hostname, options, callback) =>\n {\n const family = typeof options === 'object' && typeof options.family === 'number' ? options.family : 0;\n const wantsAll = typeof options === 'object' && options.all === true;\n\n dnsPromises.lookup(hostname, { all: true, verbatim: true, family }).then(\n (addresses) =>\n {\n const safe = policy.blockPrivateIps === false\n ? addresses\n : addresses.filter(a => !isPrivateOrReservedIp(a.address));\n\n if (safe.length === 0)\n {\n callback(new SsrfBlockedError(`Host resolves only to blocked addresses: ${hostname}`), '', 0);\n\n return;\n }\n\n if (wantsAll)\n {\n (callback as unknown as (err: null, addresses: typeof safe) => void)(null, safe);\n\n return;\n }\n\n callback(null, safe[0].address, safe[0].family);\n },\n (err: NodeJS.ErrnoException) => callback(err, '', 0),\n );\n };\n}\n\ntype FetchInput = Parameters<typeof undiciFetch>[0];\n\ntype FetchInit = Parameters<typeof undiciFetch>[1];\n\ntype FetchReturn = ReturnType<typeof undiciFetch>;\n\nfunction urlOf(input: FetchInput): string\n{\n if (typeof input === 'string')\n {\n return input;\n }\n if (input instanceof URL)\n {\n return input.href;\n }\n\n return (input as { url: string }).url;\n}\n\ntype SafeFetchFn = ((input: FetchInput, init?: FetchInit) => FetchReturn) & { _dispatcher: Agent };\n\n/**\n * Build an SSRF-safe fetch bound to a policy. Reuse the returned function (it\n * owns a pooled dispatcher) rather than calling this per request.\n *\n * Redirects are followed manually so EVERY hop is validated — including a hop\n * whose target is a bare IP literal, which undici would otherwise connect to\n * directly without invoking the pinning lookup. The original method/body and\n * headers are NOT replayed across a redirect: the next hop may be attacker-\n * chosen, so forwarding the payload or auth headers would leak them.\n */\nexport function createSafeFetch(policy: SafeFetchPolicy = {}): SafeFetchFn\n{\n const merged = { ...DEFAULTS, ...policy };\n const maxRedirects = merged.maxRedirects ?? DEFAULT_MAX_REDIRECTS;\n const dispatcher = new Agent({ connect: { lookup: pinnedLookup(merged) } });\n\n const run = async (input: FetchInput, init?: FetchInit): Promise<Awaited<FetchReturn>> =>\n {\n let url = urlOf(input);\n let hopInit: FetchInit = init;\n\n for (let hop = 0; ; hop++)\n {\n assertUrlAllowed(url, merged);\n\n const res = await undiciFetch(url, { ...hopInit, dispatcher, redirect: 'manual' });\n\n const location = res.status >= 300 && res.status < 400 ? res.headers.get('location') : null;\n if (!location)\n {\n return res;\n }\n\n await res.body?.cancel().catch(() => \n {});\n\n if (hop >= maxRedirects)\n {\n throw new SsrfBlockedError(`Too many redirects (> ${maxRedirects})`);\n }\n\n url = new URL(location, url).href;\n hopInit = { method: 'GET' };\n }\n };\n\n const fn = ((input: FetchInput, init?: FetchInit) => run(input, init)) as SafeFetchFn;\n fn._dispatcher = dispatcher;\n\n return fn;\n}\n\n// ---------------------------------------------------------------------------\n// Default policy registry — set once at server boot, read by safeFetch().\n// ---------------------------------------------------------------------------\n\nlet configuredPolicy: SafeFetchPolicy = {};\nlet cachedDefaultFetch: SafeFetchFn | undefined;\n\n/**\n * Replace the default policy used by {@link safeFetch}. Called by the server at\n * boot from `defineServerConfig().outboundFetch(...)`. Passing undefined resets\n * to the secure defaults (private IPs blocked, http/https only).\n */\nexport function setDefaultSafeFetchPolicy(policy?: SafeFetchPolicy): void\n{\n // Close the previous dispatcher's connection pool so repeated boots\n // (tests, multi-app processes) don't leak Agents.\n cachedDefaultFetch?._dispatcher.close().catch(() => \n {});\n configuredPolicy = policy ?? {};\n cachedDefaultFetch = undefined;\n}\n\n/** The effective default policy (secure defaults merged with any configured overrides). */\nexport function getDefaultSafeFetchPolicy(): SafeFetchPolicy\n{\n return { ...DEFAULTS, ...configuredPolicy };\n}\n\n/**\n * SSRF-safe `fetch` using the configured default policy. Drop-in replacement for\n * `fetch` when the URL may be influenced by user input.\n */\nexport function safeFetch(input: FetchInput, init?: FetchInit): FetchReturn\n{\n cachedDefaultFetch ??= createSafeFetch(getDefaultSafeFetchPolicy());\n\n return cachedDefaultFetch(input, init);\n}\n"]}