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