@oxyhq/core 3.9.0 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/server/cors.js +155 -0
- package/dist/cjs/server/index.js +21 -1
- package/dist/cjs/server/safeFetch.js +458 -0
- package/dist/cjs/server/verifySecret.js +50 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/server/cors.js +152 -0
- package/dist/esm/server/index.js +6 -0
- package/dist/esm/server/safeFetch.js +447 -0
- package/dist/esm/server/verifySecret.js +47 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/server/cors.d.ts +57 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/safeFetch.d.ts +135 -0
- package/dist/types/server/verifySecret.d.ts +29 -0
- package/package.json +3 -2
- package/src/server/__tests__/cors.test.ts +144 -0
- package/src/server/__tests__/safeFetch.test.ts +232 -0
- package/src/server/__tests__/verifySecret.test.ts +40 -0
- package/src/server/cors.ts +195 -0
- package/src/server/index.ts +30 -0
- package/src/server/safeFetch.ts +581 -0
- package/src/server/verifySecret.ts +52 -0
|
@@ -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
|
+
}
|
package/dist/esm/server/index.js
CHANGED
|
@@ -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
|
+
}
|