@oxyhq/core 3.9.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ /**
3
+ * Strict CORS allowlist for Oxy backends.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * App backends kept hand-rolling CORS, and the unsafe patterns recurred:
8
+ * - `Access-Control-Allow-Origin: *` together with credentials (which is
9
+ * spec-invalid AND a credential-leak vector), or
10
+ * - a "reflect whatever Origin the request carried" fallback (effectively
11
+ * `*` for credentialed requests — the Allo wildcard-fallback class).
12
+ *
13
+ * `createOxyCors` returns a self-contained Express middleware (no `cors`
14
+ * package dependency) that:
15
+ * - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
16
+ * i.e. `oxy.so` — covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
17
+ * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
18
+ * central-origin constants already in core, NOT a fresh hardcoded list,
19
+ * - allows the caller's explicit `appOrigins`,
20
+ * - DENIES everything else (no reflection, never a wildcard with credentials),
21
+ * - echoes back the EXACT matched origin (so credentialed requests work) and
22
+ * sets `Vary: Origin` for correct caching,
23
+ * - answers CORS preflight (`OPTIONS`) with `204`.
24
+ *
25
+ * Node/Express-only: exported solely from `@oxyhq/core/server`.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.createOxyCors = createOxyCors;
29
+ const authWebUrl_1 = require("../utils/authWebUrl");
30
+ const fapiAutoDetect_1 = require("../utils/fapiAutoDetect");
31
+ /** Default HTTP methods allowed across origins. */
32
+ const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
33
+ /** Default request headers a browser may send on a credentialed cross-origin call. */
34
+ const DEFAULT_ALLOWED_HEADERS = [
35
+ 'Content-Type',
36
+ 'Authorization',
37
+ 'X-Requested-With',
38
+ 'X-Oxy-User-Id',
39
+ 'X-Oxy-Internal',
40
+ 'X-CSRF-Token',
41
+ ];
42
+ /** How long (seconds) a browser may cache a successful preflight. */
43
+ const DEFAULT_MAX_AGE_SECONDS = 86400;
44
+ /**
45
+ * Whether `candidate` belongs to the Oxy apex origin family — i.e. its
46
+ * registrable apex equals {@link CENTRAL_IDP_APEX} (`oxy.so`). This matches the
47
+ * apex itself (`https://oxy.so`) and any subdomain (`https://auth.oxy.so`,
48
+ * `https://api.oxy.so`, …) over http or https, ports allowed. Returns false on
49
+ * any parse failure (fail closed).
50
+ */
51
+ function isOxyFamilyOrigin(candidate) {
52
+ let hostname;
53
+ let protocol;
54
+ try {
55
+ const url = new URL(candidate);
56
+ hostname = url.hostname.toLowerCase();
57
+ protocol = url.protocol;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ if (protocol !== 'https:' && protocol !== 'http:')
63
+ return false;
64
+ if (hostname === authWebUrl_1.CENTRAL_IDP_APEX)
65
+ return true;
66
+ return (0, fapiAutoDetect_1.registrableApex)(hostname) === authWebUrl_1.CENTRAL_IDP_APEX;
67
+ }
68
+ /** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
69
+ function normalizeOrigin(raw) {
70
+ try {
71
+ return new URL(raw).origin;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Build the origin-matching predicate: true iff `origin` is in the Oxy apex
79
+ * family OR exactly matches one of the configured app origins.
80
+ */
81
+ function buildOriginAllowed(appOrigins) {
82
+ const explicit = new Set();
83
+ for (const raw of appOrigins) {
84
+ const normalized = normalizeOrigin(raw);
85
+ if (normalized)
86
+ explicit.add(normalized);
87
+ }
88
+ return (origin) => {
89
+ const normalized = normalizeOrigin(origin);
90
+ if (normalized === null)
91
+ return false;
92
+ if (explicit.has(normalized))
93
+ return true;
94
+ return isOxyFamilyOrigin(normalized);
95
+ };
96
+ }
97
+ /**
98
+ * Create a strict Oxy CORS middleware. See module docs.
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * app.use(createOxyCors({ appOrigins: ['https://app.example.com'] }));
103
+ * ```
104
+ */
105
+ function createOxyCors(options = {}) {
106
+ const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
107
+ const isOriginAllowed = buildOriginAllowed(appOrigins);
108
+ const methodsHeader = methods.join(', ');
109
+ const allowedHeadersHeader = allowedHeaders.join(', ');
110
+ const exposedHeadersHeader = exposedHeaders.join(', ');
111
+ return (req, res, next) => {
112
+ const origin = req.headers.origin;
113
+ // Same-origin or non-browser requests carry no Origin header — pass through
114
+ // untouched (no ACAO header is emitted, which is correct for them).
115
+ if (typeof origin !== 'string' || origin.length === 0) {
116
+ if (req.method === 'OPTIONS') {
117
+ res.sendStatus(204);
118
+ return;
119
+ }
120
+ next();
121
+ return;
122
+ }
123
+ // Origin is present. Caching correctness: this response varies by Origin.
124
+ res.setHeader('Vary', 'Origin');
125
+ if (!isOriginAllowed(origin)) {
126
+ // DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
127
+ // will block the cross-origin read. Preflights for denied origins get a
128
+ // 204 with no CORS headers (the actual request then fails CORS).
129
+ if (req.method === 'OPTIONS') {
130
+ res.sendStatus(204);
131
+ return;
132
+ }
133
+ next();
134
+ return;
135
+ }
136
+ // ALLOW: echo the EXACT matched origin — never `*`, even without credentials.
137
+ res.setHeader('Access-Control-Allow-Origin', origin);
138
+ if (allowCredentials) {
139
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
140
+ }
141
+ if (exposedHeadersHeader) {
142
+ res.setHeader('Access-Control-Expose-Headers', exposedHeadersHeader);
143
+ }
144
+ if (req.method === 'OPTIONS') {
145
+ res.setHeader('Access-Control-Allow-Methods', methodsHeader);
146
+ // Honour the browser's requested headers when present, else the default set.
147
+ const requested = req.headers['access-control-request-headers'];
148
+ res.setHeader('Access-Control-Allow-Headers', typeof requested === 'string' && requested.length > 0 ? requested : allowedHeadersHeader);
149
+ res.setHeader('Access-Control-Max-Age', String(maxAgeSeconds));
150
+ res.sendStatus(204);
151
+ return;
152
+ }
153
+ next();
154
+ };
155
+ }
@@ -16,7 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.createOxyRateLimit = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
19
+ exports.verifySecret = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
20
20
  var auth_1 = require("./auth");
21
21
  Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
22
22
  Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
@@ -26,3 +26,23 @@ Object.defineProperty(exports, "isOxyAuthenticated", { enumerable: true, get: fu
26
26
  Object.defineProperty(exports, "requireOxyAuth", { enumerable: true, get: function () { return auth_1.requireOxyAuth; } });
27
27
  var rateLimit_1 = require("./rateLimit");
28
28
  Object.defineProperty(exports, "createOxyRateLimit", { enumerable: true, get: function () { return rateLimit_1.createOxyRateLimit; } });
29
+ // SSRF-safe upstream fetch + URL validation (Node-only).
30
+ var safeFetch_1 = require("./safeFetch");
31
+ Object.defineProperty(exports, "assertSafePublicUrl", { enumerable: true, get: function () { return safeFetch_1.assertSafePublicUrl; } });
32
+ Object.defineProperty(exports, "isBlockedIp", { enumerable: true, get: function () { return safeFetch_1.isBlockedIp; } });
33
+ Object.defineProperty(exports, "safeFetch", { enumerable: true, get: function () { return safeFetch_1.safeFetch; } });
34
+ Object.defineProperty(exports, "SsrfRejection", { enumerable: true, get: function () { return safeFetch_1.SsrfRejection; } });
35
+ Object.defineProperty(exports, "UpstreamError", { enumerable: true, get: function () { return safeFetch_1.UpstreamError; } });
36
+ Object.defineProperty(exports, "ALLOWED_PORTS", { enumerable: true, get: function () { return safeFetch_1.ALLOWED_PORTS; } });
37
+ Object.defineProperty(exports, "ALLOWED_PROTOCOLS", { enumerable: true, get: function () { return safeFetch_1.ALLOWED_PROTOCOLS; } });
38
+ Object.defineProperty(exports, "BLOCKED_HOSTNAMES", { enumerable: true, get: function () { return safeFetch_1.BLOCKED_HOSTNAMES; } });
39
+ Object.defineProperty(exports, "DEFAULT_USER_AGENT", { enumerable: true, get: function () { return safeFetch_1.DEFAULT_USER_AGENT; } });
40
+ Object.defineProperty(exports, "MAX_REDIRECTS", { enumerable: true, get: function () { return safeFetch_1.MAX_REDIRECTS; } });
41
+ Object.defineProperty(exports, "MAX_URL_LENGTH", { enumerable: true, get: function () { return safeFetch_1.MAX_URL_LENGTH; } });
42
+ Object.defineProperty(exports, "UPSTREAM_HEADERS_TIMEOUT_MS", { enumerable: true, get: function () { return safeFetch_1.UPSTREAM_HEADERS_TIMEOUT_MS; } });
43
+ // Strict CORS allowlist (Oxy apex family + explicit app origins).
44
+ var cors_1 = require("./cors");
45
+ Object.defineProperty(exports, "createOxyCors", { enumerable: true, get: function () { return cors_1.createOxyCors; } });
46
+ // Constant-time secret comparison.
47
+ var verifySecret_1 = require("./verifySecret");
48
+ Object.defineProperty(exports, "verifySecret", { enumerable: true, get: function () { return verifySecret_1.verifySecret; } });
@@ -0,0 +1,458 @@
1
+ "use strict";
2
+ /**
3
+ * SSRF-safe upstream HTTP fetch for Oxy backends.
4
+ *
5
+ * WHY THIS EXISTS
6
+ * ---------------
7
+ * Every Oxy backend that contacts a caller-influenced URL — media proxies,
8
+ * the website MCP image upload/debug tools, federated fetches, link
9
+ * unfurlers — needs the exact same Server-Side Request Forgery (SSRF)
10
+ * defence. Apps were re-implementing it (or worse, omitting it), so the
11
+ * gold-standard primitive (originally `packages/backend/src/utils` in
12
+ * Mention) lives here ONCE.
13
+ *
14
+ * THE CONTRACT
15
+ * ------------
16
+ * - Every URL — including each redirect hop — is validated by
17
+ * {@link assertSafePublicUrl}: a real DNS resolution plus a denylist of
18
+ * private/reserved/metadata ranges (10/8, 127/8, 169.254.169.254, ::1, …).
19
+ * - The TCP connection is PINNED to the validated IP via a custom `lookup`,
20
+ * closing the DNS-rebind TOCTOU window — DNS is NOT re-resolved at connect
21
+ * time, so the address we validated is exactly the address Node connects to.
22
+ * - Redirects are followed manually (bounded) so every hop is re-validated and
23
+ * redirect bodies (potentially unbounded) are destroyed, not drained.
24
+ *
25
+ * Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
26
+ * exported solely from `@oxyhq/core/server`. It MUST NOT be reachable from the
27
+ * browser `@oxyhq/core` entry.
28
+ */
29
+ var __importDefault = (this && this.__importDefault) || function (mod) {
30
+ return (mod && mod.__esModule) ? mod : { "default": mod };
31
+ };
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.UpstreamError = exports.SsrfRejection = exports.BLOCKED_HOSTNAMES = exports.DEFAULT_USER_AGENT = exports.MAX_REDIRECTS = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.MAX_URL_LENGTH = void 0;
34
+ exports.isBlockedIp = isBlockedIp;
35
+ exports.assertSafePublicUrl = assertSafePublicUrl;
36
+ exports.safeFetch = safeFetch;
37
+ const node_http_1 = __importDefault(require("node:http"));
38
+ const node_https_1 = __importDefault(require("node:https"));
39
+ const promises_1 = require("node:dns/promises");
40
+ const node_net_1 = require("node:net");
41
+ const node_url_1 = require("node:url");
42
+ /** Maximum accepted length of an input URL (DoS guard). */
43
+ exports.MAX_URL_LENGTH = 2048;
44
+ /** The only network ports a safe fetch is allowed to reach upstream. */
45
+ exports.ALLOWED_PORTS = new Set([80, 443]);
46
+ /** Protocols a safe fetch is allowed to contact. */
47
+ exports.ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
48
+ /**
49
+ * Time-to-first-byte deadline: how long to wait for the upstream to establish
50
+ * the connection and send its RESPONSE HEADERS before aborting. Enforced via
51
+ * `req.setTimeout` on the `ClientRequest`; once headers arrive, the caller owns
52
+ * the (longer) streaming lifetime of the response body.
53
+ */
54
+ exports.UPSTREAM_HEADERS_TIMEOUT_MS = 8000;
55
+ /** Maximum number of HTTP redirects to follow; each hop is re-validated. */
56
+ exports.MAX_REDIRECTS = 5;
57
+ /** Default User-Agent presented to upstreams when the caller does not set one. */
58
+ exports.DEFAULT_USER_AGENT = 'OxyServices/1.0 (+https://oxy.so)';
59
+ /** HTTP status codes that indicate a redirect we should follow. */
60
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
61
+ /**
62
+ * Matches hosts composed exclusively of characters that appear in numeric IP
63
+ * notations — decimal/hex digits, dots, colons and the hex marker `x`. A real
64
+ * DNS hostname always carries at least one alphabetic label character outside
65
+ * this set. Used to reject ambiguous partial/mixed numeric forms (`127.1`,
66
+ * `0x7f.1`, `0177.0.0.1`, `2130706433`) that `isIP()` does not accept as a
67
+ * literal but that the OS resolver may canonicalize into a loopback/internal
68
+ * address — and inconsistently so across glibc vs. musl (prod is Alpine/musl).
69
+ */
70
+ const AMBIGUOUS_NUMERIC_HOST = /^[0-9a-fx.:]+$/i;
71
+ /** Hostnames that must never be resolved or contacted, regardless of DNS. */
72
+ exports.BLOCKED_HOSTNAMES = new Set([
73
+ 'localhost',
74
+ 'localhost.localdomain',
75
+ 'ip6-localhost',
76
+ 'ip6-loopback',
77
+ ]);
78
+ /**
79
+ * IPv4 CIDR denylist (network, prefix-length) covering loopback, RFC1918
80
+ * private, link-local (incl. cloud metadata 169.254.169.254), shared CGNAT,
81
+ * "this host", multicast and reserved/broadcast space.
82
+ */
83
+ const BLOCKED_IPV4_CIDRS = [
84
+ ['0.0.0.0', 8], // "this" network
85
+ ['10.0.0.0', 8], // RFC1918 private
86
+ ['100.64.0.0', 10], // RFC6598 CGNAT / shared address space
87
+ ['127.0.0.0', 8], // loopback
88
+ ['169.254.0.0', 16], // link-local (cloud instance metadata 169.254.169.254)
89
+ ['172.16.0.0', 12], // RFC1918 private
90
+ ['192.0.0.0', 24], // IETF protocol assignments
91
+ ['192.0.2.0', 24], // TEST-NET-1 (documentation)
92
+ ['192.168.0.0', 16], // RFC1918 private
93
+ ['198.18.0.0', 15], // benchmarking
94
+ ['198.51.100.0', 24], // TEST-NET-2 (documentation)
95
+ ['203.0.113.0', 24], // TEST-NET-3 (documentation)
96
+ ['224.0.0.0', 4], // multicast
97
+ ['240.0.0.0', 4], // reserved / future use (incl. 255.255.255.255 broadcast)
98
+ ];
99
+ /**
100
+ * IPv6 prefix denylist (prefix, prefix-length) covering loopback, unspecified,
101
+ * unique-local (fc00::/7), link-local (fe80::/10), multicast and documentation.
102
+ * IPv4-mapped/embedded addresses are unwrapped to IPv4 before reaching here.
103
+ */
104
+ const BLOCKED_IPV6_CIDRS = [
105
+ ['::1', 128], // loopback
106
+ ['::', 128], // unspecified
107
+ ['fc00::', 7], // unique local address
108
+ ['fe80::', 10], // link-local
109
+ ['ff00::', 8], // multicast
110
+ ['2001:db8::', 32], // documentation
111
+ ['64:ff9b::', 96], // NAT64 (maps to IPv4 — IPv4 denylist still applies after unwrap)
112
+ ];
113
+ /** Number of bits in each IPv4 octet. */
114
+ const IPV4_OCTET_BITS = 8;
115
+ /** Number of octets in an IPv4 address. */
116
+ const IPV4_OCTETS = 4;
117
+ /** Number of bits in each IPv6 16-bit group. */
118
+ const IPV6_GROUP_BITS = 16;
119
+ /** Number of 16-bit groups in an IPv6 address. */
120
+ const IPV6_GROUPS = 8;
121
+ /** Convert a dotted-quad IPv4 string into its unsigned 32-bit integer value. */
122
+ function ipv4ToInt(ip) {
123
+ const parts = ip.split('.');
124
+ if (parts.length !== IPV4_OCTETS)
125
+ return null;
126
+ let value = 0;
127
+ for (const part of parts) {
128
+ if (!/^\d{1,3}$/.test(part))
129
+ return null;
130
+ const octet = Number(part);
131
+ if (octet > 255)
132
+ return null;
133
+ value = value * 256 + octet;
134
+ }
135
+ // Force unsigned 32-bit.
136
+ return value >>> 0;
137
+ }
138
+ /** Test whether an IPv4 address falls inside a CIDR block. */
139
+ function ipv4InCidr(ip, network, prefix) {
140
+ const ipInt = ipv4ToInt(ip);
141
+ const netInt = ipv4ToInt(network);
142
+ if (ipInt === null || netInt === null)
143
+ return false;
144
+ if (prefix === 0)
145
+ return true;
146
+ const mask = (0xffffffff << (IPV4_OCTET_BITS * IPV4_OCTETS - prefix)) >>> 0;
147
+ return (ipInt & mask) === (netInt & mask);
148
+ }
149
+ /** Expand an IPv6 address (possibly using `::`) into its 8 group values. */
150
+ function ipv6ToGroups(ip) {
151
+ // Strip a zone index (e.g. "fe80::1%eth0") — not relevant for range checks.
152
+ const zoneless = ip.split('%')[0];
153
+ // An IPv4-mapped/embedded tail (e.g. "::ffff:1.2.3.4") is handled by the
154
+ // caller, which unwraps to IPv4 before calling this. Reject here to be safe.
155
+ if (zoneless.includes('.'))
156
+ return null;
157
+ const halves = zoneless.split('::');
158
+ if (halves.length > 2)
159
+ return null;
160
+ const parseGroups = (segment) => {
161
+ if (segment === '')
162
+ return [];
163
+ const groups = [];
164
+ for (const part of segment.split(':')) {
165
+ if (!/^[0-9a-fA-F]{1,4}$/.test(part))
166
+ return null;
167
+ groups.push(parseInt(part, 16));
168
+ }
169
+ return groups;
170
+ };
171
+ const head = parseGroups(halves[0]);
172
+ if (head === null)
173
+ return null;
174
+ if (halves.length === 1) {
175
+ return head.length === IPV6_GROUPS ? head : null;
176
+ }
177
+ const tail = parseGroups(halves[1]);
178
+ if (tail === null)
179
+ return null;
180
+ const missing = IPV6_GROUPS - head.length - tail.length;
181
+ if (missing < 0)
182
+ return null;
183
+ return [...head, ...new Array(missing).fill(0), ...tail];
184
+ }
185
+ /** Test whether an IPv6 address falls inside a prefix block. */
186
+ function ipv6InCidr(ip, network, prefix) {
187
+ const ipGroups = ipv6ToGroups(ip);
188
+ const netGroups = ipv6ToGroups(network);
189
+ if (ipGroups === null || netGroups === null)
190
+ return false;
191
+ let bitsRemaining = prefix;
192
+ for (let i = 0; i < IPV6_GROUPS; i++) {
193
+ if (bitsRemaining <= 0)
194
+ break;
195
+ const groupBits = Math.min(IPV6_GROUP_BITS, bitsRemaining);
196
+ const mask = (0xffff << (IPV6_GROUP_BITS - groupBits)) & 0xffff;
197
+ if ((ipGroups[i] & mask) !== (netGroups[i] & mask))
198
+ return false;
199
+ bitsRemaining -= groupBits;
200
+ }
201
+ return true;
202
+ }
203
+ /**
204
+ * Unwrap an IPv4-mapped/compatible/NAT64 IPv6 address to its embedded IPv4
205
+ * dotted-quad form, so the IPv4 denylist applies. Returns null if not embedded.
206
+ */
207
+ function extractEmbeddedIpv4(ip) {
208
+ const lower = ip.toLowerCase();
209
+ // Forms like "::ffff:1.2.3.4" already carry dotted-quad notation.
210
+ const dotted = lower.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
211
+ if (dotted && (lower.startsWith('::ffff:') || lower.startsWith('::'))) {
212
+ return dotted[1];
213
+ }
214
+ // Hex form "::ffff:0102:0304" → 1.2.3.4
215
+ const hexMapped = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
216
+ if (hexMapped) {
217
+ const hi = parseInt(hexMapped[1], 16);
218
+ const lo = parseInt(hexMapped[2], 16);
219
+ return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
220
+ }
221
+ return null;
222
+ }
223
+ /**
224
+ * Return true if a literal IP address is private/loopback/link-local/reserved/
225
+ * multicast/metadata and therefore must NOT be contacted.
226
+ */
227
+ function isBlockedIp(rawIp) {
228
+ const family = (0, node_net_1.isIP)(rawIp);
229
+ if (family === 0) {
230
+ // Not a valid IP literal — treat as blocked (fail closed).
231
+ return true;
232
+ }
233
+ if (family === 4) {
234
+ return BLOCKED_IPV4_CIDRS.some(([net, prefix]) => ipv4InCidr(rawIp, net, prefix));
235
+ }
236
+ // IPv6: first unwrap any embedded IPv4 and apply the IPv4 denylist.
237
+ const embedded = extractEmbeddedIpv4(rawIp);
238
+ if (embedded !== null) {
239
+ return BLOCKED_IPV4_CIDRS.some(([net, prefix]) => ipv4InCidr(embedded, net, prefix));
240
+ }
241
+ return BLOCKED_IPV6_CIDRS.some(([net, prefix]) => ipv6InCidr(rawIp, net, prefix));
242
+ }
243
+ /**
244
+ * Validate that a URL is syntactically a public http(s) URL and that its
245
+ * hostname resolves ONLY to non-blocked, public IP addresses.
246
+ *
247
+ * On success, returns the single validated IP (the first allowed record) that
248
+ * the HTTP client MUST pin its connection to. Every resolved address is checked;
249
+ * if ANY resolves into a blocked range the URL is rejected (an attacker
250
+ * controlling a multi-record DNS response cannot smuggle one internal IP past
251
+ * the check).
252
+ *
253
+ * Re-run this on EVERY redirect hop so a public hostname cannot redirect (or
254
+ * DNS-rebind) into an internal address.
255
+ */
256
+ async function assertSafePublicUrl(rawUrl) {
257
+ if (typeof rawUrl !== 'string' || rawUrl.length === 0) {
258
+ return { ok: false, reason: 'missing url' };
259
+ }
260
+ if (rawUrl.length > exports.MAX_URL_LENGTH) {
261
+ return { ok: false, reason: 'url too long' };
262
+ }
263
+ let parsed;
264
+ try {
265
+ parsed = new node_url_1.URL(rawUrl);
266
+ }
267
+ catch {
268
+ return { ok: false, reason: 'malformed url' };
269
+ }
270
+ if (!exports.ALLOWED_PROTOCOLS.has(parsed.protocol)) {
271
+ return { ok: false, reason: `disallowed protocol ${parsed.protocol}` };
272
+ }
273
+ // Reject embedded credentials (user:pass@host) — never appropriate here.
274
+ if (parsed.username !== '' || parsed.password !== '') {
275
+ return { ok: false, reason: 'credentials in url not allowed' };
276
+ }
277
+ const hostname = parsed.hostname.toLowerCase();
278
+ if (hostname.length === 0) {
279
+ return { ok: false, reason: 'empty hostname' };
280
+ }
281
+ if (exports.BLOCKED_HOSTNAMES.has(hostname)) {
282
+ return { ok: false, reason: 'blocked hostname' };
283
+ }
284
+ // Enforce the standard-port allowlist. An empty `port` means the protocol
285
+ // default (80/443), which is allowed.
286
+ if (parsed.port !== '') {
287
+ const port = Number(parsed.port);
288
+ if (!Number.isInteger(port) || !exports.ALLOWED_PORTS.has(port)) {
289
+ return { ok: false, reason: `disallowed port ${parsed.port}` };
290
+ }
291
+ }
292
+ // If the hostname is already a literal IP, validate it directly (IPv6 hosts
293
+ // arrive bracket-wrapped from the URL parser; strip the brackets).
294
+ const literalHost = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
295
+ const literalFamily = (0, node_net_1.isIP)(literalHost);
296
+ if (literalFamily !== 0) {
297
+ if (isBlockedIp(literalHost)) {
298
+ return { ok: false, reason: 'literal ip in blocked range' };
299
+ }
300
+ return { ok: true, ip: literalHost, family: literalFamily === 4 ? 4 : 6 };
301
+ }
302
+ // Reject ambiguous numeric host forms BEFORE touching the resolver. `isIP`
303
+ // returned 0 (not a canonical IP literal), yet the host is made entirely of
304
+ // numeric/hex notation characters — e.g. `127.1`, `0x7f.1`, `0177.0.0.1`,
305
+ // `2130706433`. The OS resolver might still canonicalize these into a
306
+ // loopback/internal address (musl vs. glibc differ), so we never hand them to
307
+ // DNS. Genuine hostnames always include a non-numeric/non-hex label.
308
+ if (AMBIGUOUS_NUMERIC_HOST.test(literalHost)) {
309
+ return { ok: false, reason: 'ambiguous numeric host' };
310
+ }
311
+ // Resolve the hostname. `all: true` returns every A/AAAA record so we can
312
+ // reject if ANY of them is internal.
313
+ let records;
314
+ try {
315
+ records = await (0, promises_1.lookup)(literalHost, { all: true });
316
+ }
317
+ catch {
318
+ return { ok: false, reason: 'dns resolution failed' };
319
+ }
320
+ if (records.length === 0) {
321
+ return { ok: false, reason: 'no dns records' };
322
+ }
323
+ for (const record of records) {
324
+ if (isBlockedIp(record.address)) {
325
+ return { ok: false, reason: 'hostname resolves to blocked range' };
326
+ }
327
+ }
328
+ // All records are public. Pin the connection to the first one.
329
+ const chosen = records[0];
330
+ return {
331
+ ok: true,
332
+ ip: chosen.address,
333
+ family: chosen.family === 4 ? 4 : 6,
334
+ };
335
+ }
336
+ /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
337
+ class SsrfRejection extends Error {
338
+ constructor(reason) {
339
+ super(reason);
340
+ this.name = 'SsrfRejection';
341
+ }
342
+ }
343
+ exports.SsrfRejection = SsrfRejection;
344
+ /** Marker error for a generic upstream failure (map to 502 at the route layer). */
345
+ class UpstreamError extends Error {
346
+ constructor(reason) {
347
+ super(reason);
348
+ this.name = 'UpstreamError';
349
+ }
350
+ }
351
+ exports.UpstreamError = UpstreamError;
352
+ /**
353
+ * Build the request options for a single hop, pinning the TCP connection to the
354
+ * already-validated IP via a custom `lookup`. This closes the DNS-rebind TOCTOU
355
+ * window: the address we validated is exactly the address Node connects to.
356
+ */
357
+ function buildRequestOptions(target, pinnedIp, pinnedFamily, method, headers, signal) {
358
+ return {
359
+ protocol: target.protocol,
360
+ hostname: target.hostname,
361
+ port: target.port || (target.protocol === 'https:' ? 443 : 80),
362
+ path: `${target.pathname}${target.search}`,
363
+ method,
364
+ headers,
365
+ ...(signal ? { signal } : {}),
366
+ // Pin the connection to the validated IP — DNS is NOT re-resolved here.
367
+ //
368
+ // CRITICAL (Bun gotcha): the runtime — notably Bun's HTTP client — may
369
+ // invoke a custom lookup with `{ all: true }` and then sort the result
370
+ // internally (`results.sort(...)`). When `all` is requested we MUST return
371
+ // an ARRAY of `{ address, family }`; returning a single value makes that
372
+ // internal sort throw `results.sort is not a function`. Node (non-`all`)
373
+ // expects the `(err, address, family)` triple. Handle both.
374
+ lookup: ((_hostname, options, callback) => {
375
+ const wantsAll = typeof options === 'object' && options !== null && options.all === true;
376
+ if (wantsAll) {
377
+ callback(null, [{ address: pinnedIp, family: pinnedFamily }]);
378
+ }
379
+ else {
380
+ callback(null, pinnedIp, pinnedFamily);
381
+ }
382
+ }),
383
+ };
384
+ }
385
+ /** Perform a single upstream request (no auto-redirect). */
386
+ function fetchOnce(options, isHttps, headersTimeoutMs) {
387
+ return new Promise((resolve, reject) => {
388
+ const transport = isHttps ? node_https_1.default : node_http_1.default;
389
+ const req = transport.request(options, (res) => resolve(res));
390
+ req.setTimeout(headersTimeoutMs, () => {
391
+ req.destroy(new UpstreamError('upstream headers timeout'));
392
+ });
393
+ req.on('error', (err) => reject(err));
394
+ req.end();
395
+ });
396
+ }
397
+ /**
398
+ * SSRF-safe HTTP(S) fetch. Validates the URL (and every redirect hop) against
399
+ * the private/metadata-range denylist, pins the connection to the validated IP,
400
+ * follows a bounded number of redirects (destroying redirect bodies), and
401
+ * returns the first non-redirect response.
402
+ *
403
+ * The caller owns the returned response stream — drain or destroy it.
404
+ *
405
+ * @throws {SsrfRejection} when any hop targets a blocked address/host/port.
406
+ * @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
407
+ */
408
+ async function safeFetch(rawUrl, options = {}) {
409
+ const { method = 'GET', headers: callerHeaders, maxRedirects = exports.MAX_REDIRECTS, headersTimeoutMs = exports.UPSTREAM_HEADERS_TIMEOUT_MS, signal, } = options;
410
+ // Normalize a case-insensitive header map and ensure a User-Agent default.
411
+ const baseHeaders = {};
412
+ if (callerHeaders) {
413
+ for (const [k, v] of Object.entries(callerHeaders)) {
414
+ baseHeaders[k] = v;
415
+ }
416
+ }
417
+ const hasUserAgent = Object.keys(baseHeaders).some((k) => k.toLowerCase() === 'user-agent');
418
+ if (!hasUserAgent) {
419
+ baseHeaders['User-Agent'] = exports.DEFAULT_USER_AGENT;
420
+ }
421
+ let currentUrl = rawUrl;
422
+ for (let hop = 0; hop <= maxRedirects; hop++) {
423
+ if (signal?.aborted) {
424
+ throw new UpstreamError('request aborted');
425
+ }
426
+ const guard = await assertSafePublicUrl(currentUrl);
427
+ if (!guard.ok) {
428
+ throw new SsrfRejection(guard.reason);
429
+ }
430
+ const target = new node_url_1.URL(currentUrl);
431
+ const requestOptions = buildRequestOptions(target, guard.ip, guard.family, method, baseHeaders, signal);
432
+ const response = await fetchOnce(requestOptions, target.protocol === 'https:', headersTimeoutMs);
433
+ const status = response.statusCode ?? 0;
434
+ if (REDIRECT_STATUS_CODES.has(status)) {
435
+ const location = response.headers.location;
436
+ // We only need the Location header. Destroy immediately rather than
437
+ // draining the (potentially unbounded) redirect body.
438
+ response.destroy();
439
+ if (hop === maxRedirects) {
440
+ throw new UpstreamError('too many redirects');
441
+ }
442
+ if (!location || typeof location !== 'string') {
443
+ throw new UpstreamError('redirect without location');
444
+ }
445
+ // Resolve relative redirects against the current URL.
446
+ currentUrl = new node_url_1.URL(location, currentUrl).toString();
447
+ continue;
448
+ }
449
+ return {
450
+ response,
451
+ status,
452
+ headers: response.headers,
453
+ finalUrl: currentUrl,
454
+ };
455
+ }
456
+ // Unreachable: the loop either returns a response or throws.
457
+ throw new UpstreamError('redirect loop exhausted');
458
+ }