@spfn/core 0.2.0-beta.57 → 0.2.0-beta.59

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.
@@ -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"]}
@@ -3,7 +3,8 @@ import { MiddlewareHandler, Hono } from 'hono';
3
3
  import { cors } from 'hono/cors';
4
4
  import { serve } from '@hono/node-server';
5
5
  import { NamedMiddleware, Router } from '@spfn/core/route';
6
- import { OnErrorContext, ProxyGuardConfig } from '@spfn/core/middleware';
6
+ import { OnErrorContext, ProxyGuardConfig, RateLimitOptions } from '@spfn/core/middleware';
7
+ import { SafeFetchPolicy } from '@spfn/core/security';
7
8
  import { J as JobRouter, B as BossOptions } from '../boss-CjClX7G4.js';
8
9
  import { E as EventRouterDef } from '../token-manager-jKD_EsSE.js';
9
10
  import { S as SSEHandlerConfig, a as SSEAuthConfig } from '../types-BFB72jbM.js';
@@ -118,6 +119,44 @@ interface ServerConfig {
118
119
  */
119
120
  nonce?: boolean;
120
121
  };
122
+ /**
123
+ * Rate limiting: an optional global default limiter plus named policies.
124
+ *
125
+ * `mode: 'on'` applies `default` to every named-middleware route (opt out
126
+ * with `.skip(['rateLimit'])`); `policies` lets packages tag sensitive routes
127
+ * via `rateLimitPolicy(name, fallback)` while this app tunes the numbers in
128
+ * one place. Backed by the shared cache (CACHE_URL); without a cache it fails
129
+ * open unless `default.failClosed` is set. Disabled by default (`mode: 'off'`).
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * .rateLimit({
134
+ * mode: 'on',
135
+ * default: { limit: 100, windowMs: 60_000 },
136
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
137
+ * })
138
+ * ```
139
+ */
140
+ rateLimit?: {
141
+ /** 'on' applies the default limiter to every route. @default 'off' */
142
+ mode?: 'off' | 'on';
143
+ /** Default policy applied to all routes when `mode` is 'on'. */
144
+ default?: RateLimitOptions;
145
+ /** Named policies referenced by `rateLimitPolicy(name, fallback)` tags. */
146
+ policies?: Record<string, RateLimitOptions>;
147
+ };
148
+ /**
149
+ * SSRF policy for outbound requests made via `safeFetch` (`@spfn/core/security`).
150
+ * Sets the process-wide default used by webhook/callback senders. Private and
151
+ * reserved IPs are blocked by default; set `allowHosts` to restrict to a known
152
+ * set of upstreams, or `blockPrivateIps: false` for trusted internal calls.
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
157
+ * ```
158
+ */
159
+ outboundFetch?: SafeFetchPolicy;
121
160
  /**
122
161
  * Global middlewares with names for route-level skip control
123
162
  * Use defineMiddleware() for type-safe middleware definitions
@@ -814,6 +853,30 @@ declare class ServerConfigBuilder {
814
853
  * Configure proxy-guard (verify trusted-proxy signature + origin → clientType)
815
854
  */
816
855
  proxyGuard(proxyGuard: ServerConfig['proxyGuard']): this;
856
+ /**
857
+ * Configure rate limiting: an optional global default limiter plus the named
858
+ * policies that `rateLimitPolicy(name, fallback)` tags resolve against.
859
+ *
860
+ * @example
861
+ * ```typescript
862
+ * .rateLimit({
863
+ * mode: 'on',
864
+ * default: { limit: 100, windowMs: 60_000 },
865
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
866
+ * })
867
+ * ```
868
+ */
869
+ rateLimit(rateLimit: ServerConfig['rateLimit']): this;
870
+ /**
871
+ * Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
872
+ * callbacks). Private/reserved IPs are blocked by default.
873
+ *
874
+ * @example
875
+ * ```typescript
876
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
877
+ * ```
878
+ */
879
+ outboundFetch(outboundFetch: ServerConfig['outboundFetch']): this;
817
880
  /**
818
881
  * Register define-route based router
819
882
  *
@@ -5,8 +5,9 @@ import { parse } from 'dotenv';
5
5
  import { logger } from '@spfn/core/logger';
6
6
  import { Hono } from 'hono';
7
7
  import { cors } from 'hono/cors';
8
- import { registerRoutes } from '@spfn/core/route';
9
- import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
8
+ import { registerRoutes, defineMiddleware } from '@spfn/core/route';
9
+ import { ErrorHandler, RequestLogger, setRateLimitPolicies, setRateLimitFailClosedDefault, rateLimit } from '@spfn/core/middleware';
10
+ import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
10
11
  import { streamSSE } from 'hono/streaming';
11
12
  import { randomBytes, createHash } from 'crypto';
12
13
  import { Agent, setGlobalDispatcher } from 'undici';
@@ -1332,7 +1333,9 @@ function buildStartupConfig(config, timeouts) {
1332
1333
  }
1333
1334
 
1334
1335
  // src/server/create-server.ts
1336
+ var rateLimitApplied = /* @__PURE__ */ new WeakSet();
1335
1337
  async function createServer(config) {
1338
+ applyOutboundFetch(config);
1336
1339
  const cwd = process.cwd();
1337
1340
  const appPath = join(cwd, "src", "server", "app.ts");
1338
1341
  const appJsPath = join(cwd, "src", "server", "app");
@@ -1349,6 +1352,7 @@ async function loadCustomApp(appPath, appJsPath, config) {
1349
1352
  throw new Error("app.ts must export a default function that returns a Hono app");
1350
1353
  }
1351
1354
  const app = await appFactory();
1355
+ applyRateLimit(config);
1352
1356
  if (config?.routes) {
1353
1357
  const routes = registerRoutes(app, config.routes, config.middlewares);
1354
1358
  logRegisteredRoutes(routes, config?.debug ?? false);
@@ -1369,6 +1373,7 @@ async function createAutoConfiguredApp(config) {
1369
1373
  }
1370
1374
  applyDefaultMiddleware(app, config, enableLogger, enableCors);
1371
1375
  await applyProxyGuard(app, config);
1376
+ applyRateLimit(config);
1372
1377
  if (Array.isArray(config?.use)) {
1373
1378
  config.use.forEach((mw) => app.use("*", mw));
1374
1379
  }
@@ -1436,6 +1441,28 @@ async function applyProxyGuard(app, config) {
1436
1441
  }));
1437
1442
  serverLogger.info(`\u2713 Proxy-guard enabled (mode: ${mode})`);
1438
1443
  }
1444
+ function applyRateLimit(config) {
1445
+ const rl = config?.rateLimit;
1446
+ setRateLimitPolicies(rl?.policies);
1447
+ setRateLimitFailClosedDefault(env.RATE_LIMIT_FAIL_CLOSED);
1448
+ const enabled = (rl?.mode ?? env.RATE_LIMIT_MODE) === "on";
1449
+ if (!enabled || !config || rateLimitApplied.has(config)) {
1450
+ return;
1451
+ }
1452
+ rateLimitApplied.add(config);
1453
+ const defaultPolicy = {
1454
+ limit: rl?.default?.limit ?? env.RATE_LIMIT_DEFAULT_LIMIT,
1455
+ windowMs: rl?.default?.windowMs ?? env.RATE_LIMIT_DEFAULT_WINDOW_MS,
1456
+ failClosed: rl?.default?.failClosed ?? env.RATE_LIMIT_FAIL_CLOSED
1457
+ };
1458
+ const globalRateLimit = defineMiddleware("rateLimit", rateLimit(defaultPolicy));
1459
+ config.middlewares = [globalRateLimit, ...config.middlewares ?? []];
1460
+ serverLogger.info(`\u2713 Rate limit default enabled (${defaultPolicy.limit} per ${defaultPolicy.windowMs}ms)`);
1461
+ }
1462
+ function applyOutboundFetch(config) {
1463
+ const policy = config?.outboundFetch ?? { blockPrivateIps: env.SAFE_FETCH_BLOCK_PRIVATE_IPS };
1464
+ setDefaultSafeFetchPolicy(policy);
1465
+ }
1439
1466
  function registerHealthCheckEndpoint(app, config) {
1440
1467
  const healthCheckConfig = config?.healthCheck ?? {};
1441
1468
  const healthCheckEnabled = healthCheckConfig.enabled !== false;
@@ -2632,6 +2659,36 @@ var ServerConfigBuilder = class {
2632
2659
  this.config.proxyGuard = proxyGuard;
2633
2660
  return this;
2634
2661
  }
2662
+ /**
2663
+ * Configure rate limiting: an optional global default limiter plus the named
2664
+ * policies that `rateLimitPolicy(name, fallback)` tags resolve against.
2665
+ *
2666
+ * @example
2667
+ * ```typescript
2668
+ * .rateLimit({
2669
+ * mode: 'on',
2670
+ * default: { limit: 100, windowMs: 60_000 },
2671
+ * policies: { 'auth-login': { limit: 5, windowMs: 60_000 } },
2672
+ * })
2673
+ * ```
2674
+ */
2675
+ rateLimit(rateLimit2) {
2676
+ this.config.rateLimit = rateLimit2;
2677
+ return this;
2678
+ }
2679
+ /**
2680
+ * Configure the SSRF policy for outbound `safeFetch` calls (webhooks,
2681
+ * callbacks). Private/reserved IPs are blocked by default.
2682
+ *
2683
+ * @example
2684
+ * ```typescript
2685
+ * .outboundFetch({ allowHosts: ['hooks.slack.com'] })
2686
+ * ```
2687
+ */
2688
+ outboundFetch(outboundFetch) {
2689
+ this.config.outboundFetch = outboundFetch;
2690
+ return this;
2691
+ }
2635
2692
  /**
2636
2693
  * Register define-route based router
2637
2694
  *