@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.
@@ -0,0 +1,57 @@
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 type { RequestHandler } from 'express';
27
+ export interface OxyCorsOptions {
28
+ /**
29
+ * Explicit additional allowed origins (exact-origin match, e.g.
30
+ * `https://app.example.com`, `http://localhost:3000`). These are allowed IN
31
+ * ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
32
+ */
33
+ appOrigins?: string[];
34
+ /**
35
+ * Whether to emit `Access-Control-Allow-Credentials: true`. Default `true`
36
+ * (the Oxy ecosystem uses cookie/bearer credentials). Even when `true`, the
37
+ * helper NEVER emits a wildcard origin — only an exact matched origin.
38
+ */
39
+ allowCredentials?: boolean;
40
+ /** HTTP methods to allow. Defaults to the full standard set. */
41
+ methods?: string[];
42
+ /** Request headers to allow. Defaults to the common Oxy set. */
43
+ allowedHeaders?: string[];
44
+ /** Response headers to expose to the browser. Defaults to none. */
45
+ exposedHeaders?: string[];
46
+ /** Preflight cache lifetime in seconds. Default 86400 (24h). */
47
+ maxAgeSeconds?: number;
48
+ }
49
+ /**
50
+ * Create a strict Oxy CORS middleware. See module docs.
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * app.use(createOxyCors({ appOrigins: ['https://app.example.com'] }));
55
+ * ```
56
+ */
57
+ export declare function createOxyCors(options?: OxyCorsOptions): RequestHandler;
@@ -18,3 +18,8 @@ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequir
18
18
  export type { OxyActingAsContext, OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
19
19
  export { createOxyRateLimit } from './rateLimit';
20
20
  export type { OxyRateLimitOptions } from './rateLimit';
21
+ 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';
22
+ export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
23
+ export { createOxyCors } from './cors';
24
+ export type { OxyCorsOptions } from './cors';
25
+ export { verifySecret } from './verifySecret';
@@ -0,0 +1,135 @@
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 { type IncomingMessage, type IncomingHttpHeaders } from 'node:http';
29
+ /** Maximum accepted length of an input URL (DoS guard). */
30
+ export declare const MAX_URL_LENGTH = 2048;
31
+ /** The only network ports a safe fetch is allowed to reach upstream. */
32
+ export declare const ALLOWED_PORTS: ReadonlySet<number>;
33
+ /** Protocols a safe fetch is allowed to contact. */
34
+ export declare const ALLOWED_PROTOCOLS: ReadonlySet<string>;
35
+ /**
36
+ * Time-to-first-byte deadline: how long to wait for the upstream to establish
37
+ * the connection and send its RESPONSE HEADERS before aborting. Enforced via
38
+ * `req.setTimeout` on the `ClientRequest`; once headers arrive, the caller owns
39
+ * the (longer) streaming lifetime of the response body.
40
+ */
41
+ export declare const UPSTREAM_HEADERS_TIMEOUT_MS = 8000;
42
+ /** Maximum number of HTTP redirects to follow; each hop is re-validated. */
43
+ export declare const MAX_REDIRECTS = 5;
44
+ /** Default User-Agent presented to upstreams when the caller does not set one. */
45
+ export declare const DEFAULT_USER_AGENT = "OxyServices/1.0 (+https://oxy.so)";
46
+ /** Hostnames that must never be resolved or contacted, regardless of DNS. */
47
+ export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
48
+ export interface SsrfCheckOk {
49
+ ok: true;
50
+ /** The validated literal IP the caller MUST connect to. */
51
+ ip: string;
52
+ /** IP family of the validated address (4 or 6). */
53
+ family: 4 | 6;
54
+ }
55
+ export interface SsrfCheckFail {
56
+ ok: false;
57
+ /** Human-readable, non-sensitive reason (safe to log; not echoed to clients). */
58
+ reason: string;
59
+ }
60
+ export type SsrfCheckResult = SsrfCheckOk | SsrfCheckFail;
61
+ /**
62
+ * Return true if a literal IP address is private/loopback/link-local/reserved/
63
+ * multicast/metadata and therefore must NOT be contacted.
64
+ */
65
+ export declare function isBlockedIp(rawIp: string): boolean;
66
+ /**
67
+ * Validate that a URL is syntactically a public http(s) URL and that its
68
+ * hostname resolves ONLY to non-blocked, public IP addresses.
69
+ *
70
+ * On success, returns the single validated IP (the first allowed record) that
71
+ * the HTTP client MUST pin its connection to. Every resolved address is checked;
72
+ * if ANY resolves into a blocked range the URL is rejected (an attacker
73
+ * controlling a multi-record DNS response cannot smuggle one internal IP past
74
+ * the check).
75
+ *
76
+ * Re-run this on EVERY redirect hop so a public hostname cannot redirect (or
77
+ * DNS-rebind) into an internal address.
78
+ */
79
+ export declare function assertSafePublicUrl(rawUrl: string): Promise<SsrfCheckResult>;
80
+ /** Marker error for a blocked SSRF target (map to 403 at the route layer). */
81
+ export declare class SsrfRejection extends Error {
82
+ constructor(reason: string);
83
+ }
84
+ /** Marker error for a generic upstream failure (map to 502 at the route layer). */
85
+ export declare class UpstreamError extends Error {
86
+ constructor(reason: string);
87
+ }
88
+ /** Options for {@link safeFetch}. */
89
+ export interface SafeFetchOptions {
90
+ /** HTTP method. Defaults to `GET`. */
91
+ method?: string;
92
+ /** Extra request headers. A `User-Agent` is added if none is provided. */
93
+ headers?: Record<string, string>;
94
+ /**
95
+ * Maximum number of redirects to follow (each re-validated). Defaults to
96
+ * {@link MAX_REDIRECTS}. Set to `0` to disallow redirects.
97
+ */
98
+ maxRedirects?: number;
99
+ /**
100
+ * Time-to-first-byte deadline in milliseconds (connect + response headers).
101
+ * Defaults to {@link UPSTREAM_HEADERS_TIMEOUT_MS}.
102
+ */
103
+ headersTimeoutMs?: number;
104
+ /**
105
+ * Optional external abort signal. When it fires the in-flight request is
106
+ * destroyed.
107
+ */
108
+ signal?: AbortSignal;
109
+ }
110
+ /** The validated, non-redirect response returned by {@link safeFetch}. */
111
+ export interface SafeFetchResult {
112
+ /**
113
+ * The first non-redirect response. The caller OWNS draining/destroying this
114
+ * stream (stream it to the client, or buffer a bounded prefix, then destroy).
115
+ */
116
+ response: IncomingMessage;
117
+ /** The HTTP status code of the response. */
118
+ status: number;
119
+ /** Response headers. */
120
+ headers: IncomingHttpHeaders;
121
+ /** The final, post-redirect URL that produced the response. */
122
+ finalUrl: string;
123
+ }
124
+ /**
125
+ * SSRF-safe HTTP(S) fetch. Validates the URL (and every redirect hop) against
126
+ * the private/metadata-range denylist, pins the connection to the validated IP,
127
+ * follows a bounded number of redirects (destroying redirect bodies), and
128
+ * returns the first non-redirect response.
129
+ *
130
+ * The caller owns the returned response stream — drain or destroy it.
131
+ *
132
+ * @throws {SsrfRejection} when any hop targets a blocked address/host/port.
133
+ * @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
134
+ */
135
+ export declare function safeFetch(rawUrl: string, options?: SafeFetchOptions): Promise<SafeFetchResult>;
@@ -0,0 +1,29 @@
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
+ /**
16
+ * Compare two secrets in constant time.
17
+ *
18
+ * Returns `true` iff both are non-empty strings of equal byte length with
19
+ * identical contents. Returns `false` — without throwing — when either value is
20
+ * not a string, when lengths differ, or when contents differ.
21
+ *
22
+ * The length-equality guard is required because `crypto.timingSafeEqual` throws
23
+ * on unequal-length buffers; comparing lengths first leaks only the LENGTH of
24
+ * the expected secret (already low-value / often public), never its bytes.
25
+ *
26
+ * @param provided - The untrusted, caller-supplied value (e.g. a request token).
27
+ * @param expected - The trusted secret to compare against.
28
+ */
29
+ export declare function verifySecret(provided: string, expected: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -80,6 +80,7 @@
80
80
  "typescript": "tsc --noEmit",
81
81
  "test": "jest --passWithNoTests",
82
82
  "lint": "biome lint --error-on-warnings ./src",
83
+ "prepublishOnly": "bun run clean && bun run build",
83
84
  "release": "rm -rf dist && bun run build && release-it"
84
85
  },
85
86
  "release-it": {
@@ -97,7 +98,7 @@
97
98
  }
98
99
  },
99
100
  "dependencies": {
100
- "@oxyhq/contracts": "^0.2.0",
101
+ "@oxyhq/contracts": "^0.2.1",
101
102
  "bip39": "^3.1.0",
102
103
  "buffer": "^6.0.3",
103
104
  "elliptic": "^6.6.1",
@@ -0,0 +1,144 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ import { createOxyCors } from '../cors';
3
+
4
+ interface FakeResponse extends Response {
5
+ __headers: Record<string, string>;
6
+ __statusSent: number | null;
7
+ }
8
+
9
+ function makeRequest(method: string, origin?: string, acrHeaders?: string): Request {
10
+ const headers: Record<string, string> = {};
11
+ if (origin !== undefined) headers.origin = origin;
12
+ if (acrHeaders !== undefined) headers['access-control-request-headers'] = acrHeaders;
13
+ return { method, headers } as unknown as Request;
14
+ }
15
+
16
+ function makeResponse(): FakeResponse {
17
+ const res = {
18
+ __headers: {},
19
+ __statusSent: null,
20
+ } as FakeResponse;
21
+ res.setHeader = jest.fn((name: string, value: string | number | readonly string[]) => {
22
+ res.__headers[name] = String(value);
23
+ return res;
24
+ }) as unknown as Response['setHeader'];
25
+ res.sendStatus = jest.fn((code: number) => {
26
+ res.__statusSent = code;
27
+ return res;
28
+ }) as unknown as Response['sendStatus'];
29
+ return res;
30
+ }
31
+
32
+ function makeNext(): NextFunction & jest.Mock {
33
+ return jest.fn() as unknown as NextFunction & jest.Mock;
34
+ }
35
+
36
+ describe('@oxyhq/core/server createOxyCors', () => {
37
+ it('allows the Oxy apex family (apex + any subdomain) and echoes the exact origin', () => {
38
+ const mw = createOxyCors();
39
+ for (const origin of [
40
+ 'https://oxy.so',
41
+ 'https://auth.oxy.so',
42
+ 'https://api.oxy.so',
43
+ 'https://accounts.oxy.so',
44
+ 'https://console.oxy.so',
45
+ 'https://inbox.oxy.so',
46
+ ]) {
47
+ const req = makeRequest('GET', origin);
48
+ const res = makeResponse();
49
+ const next = makeNext();
50
+ mw(req, res, next);
51
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe(origin);
52
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBe('true');
53
+ expect(res.__headers.Vary).toBe('Origin');
54
+ expect(next).toHaveBeenCalledTimes(1);
55
+ }
56
+ });
57
+
58
+ it('allows explicit appOrigins', () => {
59
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com', 'http://localhost:3000'] });
60
+ for (const origin of ['https://app.example.com', 'http://localhost:3000']) {
61
+ const req = makeRequest('GET', origin);
62
+ const res = makeResponse();
63
+ const next = makeNext();
64
+ mw(req, res, next);
65
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe(origin);
66
+ expect(next).toHaveBeenCalledTimes(1);
67
+ }
68
+ });
69
+
70
+ it('DENIES other origins — never reflects them, never wildcards', () => {
71
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com'] });
72
+ for (const origin of [
73
+ 'https://evil.com',
74
+ 'https://oxy.so.evil.com', // suffix attack
75
+ 'https://notoxy.so', // different apex
76
+ 'https://example.com',
77
+ ]) {
78
+ const req = makeRequest('GET', origin);
79
+ const res = makeResponse();
80
+ const next = makeNext();
81
+ mw(req, res, next);
82
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
83
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe('*');
84
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe(origin);
85
+ // request still passes to the app; the browser enforces the missing ACAO.
86
+ expect(next).toHaveBeenCalledTimes(1);
87
+ }
88
+ });
89
+
90
+ it('NEVER emits wildcard ACAO together with credentials', () => {
91
+ const mw = createOxyCors({ allowCredentials: true });
92
+ // Even an allowed origin gets the exact origin, never '*'.
93
+ const req = makeRequest('GET', 'https://auth.oxy.so');
94
+ const res = makeResponse();
95
+ mw(req, res, makeNext());
96
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://auth.oxy.so');
97
+ expect(res.__headers['Access-Control-Allow-Origin']).not.toBe('*');
98
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBe('true');
99
+ });
100
+
101
+ it('answers preflight (OPTIONS) for an allowed origin with 204 + method/header allows', () => {
102
+ const mw = createOxyCors({ appOrigins: ['https://app.example.com'] });
103
+ const req = makeRequest('OPTIONS', 'https://app.example.com', 'content-type, authorization');
104
+ const res = makeResponse();
105
+ const next = makeNext();
106
+ mw(req, res, next);
107
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://app.example.com');
108
+ expect(res.__headers['Access-Control-Allow-Methods']).toContain('GET');
109
+ expect(res.__headers['Access-Control-Allow-Headers']).toBe('content-type, authorization');
110
+ expect(res.__headers['Access-Control-Max-Age']).toBeDefined();
111
+ expect(res.__statusSent).toBe(204);
112
+ expect(next).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it('answers preflight for a DENIED origin with 204 and NO CORS headers', () => {
116
+ const mw = createOxyCors();
117
+ const req = makeRequest('OPTIONS', 'https://evil.com');
118
+ const res = makeResponse();
119
+ const next = makeNext();
120
+ mw(req, res, next);
121
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
122
+ expect(res.__statusSent).toBe(204);
123
+ expect(next).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('passes through same-origin / non-browser requests (no Origin header) without ACAO', () => {
127
+ const mw = createOxyCors();
128
+ const req = makeRequest('GET');
129
+ const res = makeResponse();
130
+ const next = makeNext();
131
+ mw(req, res, next);
132
+ expect(res.__headers['Access-Control-Allow-Origin']).toBeUndefined();
133
+ expect(next).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('can disable credentials and still never wildcards', () => {
137
+ const mw = createOxyCors({ allowCredentials: false });
138
+ const req = makeRequest('GET', 'https://api.oxy.so');
139
+ const res = makeResponse();
140
+ mw(req, res, makeNext());
141
+ expect(res.__headers['Access-Control-Allow-Origin']).toBe('https://api.oxy.so');
142
+ expect(res.__headers['Access-Control-Allow-Credentials']).toBeUndefined();
143
+ });
144
+ });
@@ -0,0 +1,232 @@
1
+ import type { LookupAddress, LookupAllOptions, LookupOneOptions } from 'node:dns';
2
+ import type { LookupFunction } from 'node:net';
3
+
4
+ // Mock node:dns/promises so the static `import { lookup }` binding in safeFetch
5
+ // is intercepted (spying on the namespace doesn't work — the binding is
6
+ // resolved at module load, and the real `lookup` property is non-configurable).
7
+ const mockDnsLookup = jest.fn();
8
+ jest.mock('node:dns/promises', () => ({
9
+ lookup: (...args: unknown[]) => mockDnsLookup(...args),
10
+ }));
11
+
12
+ import {
13
+ assertSafePublicUrl,
14
+ isBlockedIp,
15
+ } from '../safeFetch';
16
+
17
+ beforeEach(() => {
18
+ mockDnsLookup.mockReset();
19
+ });
20
+
21
+ describe('@oxyhq/core/server safeFetch — isBlockedIp', () => {
22
+ it('blocks private / loopback / metadata / reserved IPv4 ranges', () => {
23
+ const blocked = [
24
+ '127.0.0.1', // loopback
25
+ '10.0.0.1', // RFC1918
26
+ '10.255.255.255',
27
+ '172.16.0.1', // RFC1918
28
+ '172.31.255.255',
29
+ '192.168.1.1', // RFC1918
30
+ '169.254.169.254', // cloud metadata
31
+ '169.254.0.1', // link-local
32
+ '100.64.0.1', // CGNAT
33
+ '0.0.0.0', // this network
34
+ '224.0.0.1', // multicast
35
+ '255.255.255.255', // broadcast
36
+ '198.18.0.1', // benchmarking
37
+ ];
38
+ for (const ip of blocked) {
39
+ expect(isBlockedIp(ip)).toBe(true);
40
+ }
41
+ });
42
+
43
+ it('blocks loopback / ULA / link-local IPv6 ranges and IPv4-mapped internals', () => {
44
+ const blocked = [
45
+ '::1', // loopback
46
+ '::', // unspecified
47
+ 'fc00::1', // unique local
48
+ 'fe80::1', // link-local
49
+ 'ff02::1', // multicast
50
+ '::ffff:127.0.0.1', // IPv4-mapped loopback
51
+ '::ffff:169.254.169.254', // IPv4-mapped metadata
52
+ ];
53
+ for (const ip of blocked) {
54
+ expect(isBlockedIp(ip)).toBe(true);
55
+ }
56
+ });
57
+
58
+ it('allows genuine public IPs', () => {
59
+ expect(isBlockedIp('1.1.1.1')).toBe(false);
60
+ expect(isBlockedIp('8.8.8.8')).toBe(false);
61
+ expect(isBlockedIp('93.184.216.34')).toBe(false); // example.com historical
62
+ expect(isBlockedIp('2606:4700:4700::1111')).toBe(false); // public IPv6
63
+ });
64
+
65
+ it('fails closed for non-IP literals', () => {
66
+ expect(isBlockedIp('not-an-ip')).toBe(true);
67
+ expect(isBlockedIp('')).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('@oxyhq/core/server safeFetch — assertSafePublicUrl', () => {
72
+ it('rejects literal private / metadata IP URLs without any DNS', async () => {
73
+ const cases: Array<[string, RegExp]> = [
74
+ ['http://169.254.169.254/latest/meta-data/', /blocked range/],
75
+ ['http://127.0.0.1/', /blocked range/],
76
+ ['http://10.0.0.1/', /blocked range/],
77
+ ['http://192.168.0.1/', /blocked range/],
78
+ ['http://[::1]/', /blocked range/],
79
+ ];
80
+ for (const [url, reason] of cases) {
81
+ const result = await assertSafePublicUrl(url);
82
+ expect(result.ok).toBe(false);
83
+ if (!result.ok) expect(result.reason).toMatch(reason);
84
+ }
85
+ });
86
+
87
+ it('rejects blocked hostnames before resolving', async () => {
88
+ const result = await assertSafePublicUrl('http://localhost/');
89
+ expect(result.ok).toBe(false);
90
+ if (!result.ok) expect(result.reason).toBe('blocked hostname');
91
+ });
92
+
93
+ it('rejects ambiguous numeric host forms before touching DNS', async () => {
94
+ for (const url of [
95
+ 'http://2130706433/', // decimal 127.0.0.1
96
+ 'http://0x7f.1/',
97
+ 'http://0177.0.0.1/',
98
+ 'http://127.1/',
99
+ ]) {
100
+ const result = await assertSafePublicUrl(url);
101
+ expect(result.ok).toBe(false);
102
+ if (!result.ok) {
103
+ expect(['ambiguous numeric host', 'literal ip in blocked range']).toContain(result.reason);
104
+ }
105
+ }
106
+ });
107
+
108
+ it('rejects disallowed protocols, ports, credentials, and oversized URLs', async () => {
109
+ expect((await assertSafePublicUrl('ftp://example.com/')).ok).toBe(false);
110
+ expect((await assertSafePublicUrl('file:///etc/passwd')).ok).toBe(false);
111
+ expect((await assertSafePublicUrl('http://example.com:22/')).ok).toBe(false);
112
+ expect((await assertSafePublicUrl('http://user:pass@example.com/')).ok).toBe(false);
113
+ expect((await assertSafePublicUrl('not a url')).ok).toBe(false);
114
+ expect((await assertSafePublicUrl(`http://example.com/${'a'.repeat(3000)}`)).ok).toBe(false);
115
+ expect((await assertSafePublicUrl('')).ok).toBe(false);
116
+ });
117
+
118
+ it('allows a literal public IP URL and pins the connection IP/family (no DNS)', async () => {
119
+ const result = await assertSafePublicUrl('https://1.1.1.1/');
120
+ expect(result.ok).toBe(true);
121
+ if (result.ok) {
122
+ expect(result.ip).toBe('1.1.1.1');
123
+ expect(result.family).toBe(4);
124
+ }
125
+ expect(mockDnsLookup).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('rejects a public hostname that resolves into a blocked range (rebind defence)', async () => {
129
+ mockDnsLookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
130
+ const result = await assertSafePublicUrl('https://attacker.example/');
131
+ expect(result.ok).toBe(false);
132
+ if (!result.ok) expect(result.reason).toBe('hostname resolves to blocked range');
133
+ expect(mockDnsLookup).toHaveBeenCalledWith('attacker.example', { all: true });
134
+ });
135
+
136
+ it('rejects when ANY of multiple resolved records is internal', async () => {
137
+ mockDnsLookup.mockResolvedValue([
138
+ { address: '93.184.216.34', family: 4 }, // public
139
+ { address: '169.254.169.254', family: 4 }, // metadata smuggled in
140
+ ]);
141
+ const result = await assertSafePublicUrl('https://multi.example/');
142
+ expect(result.ok).toBe(false);
143
+ if (!result.ok) expect(result.reason).toBe('hostname resolves to blocked range');
144
+ });
145
+
146
+ it('rejects when DNS resolution fails', async () => {
147
+ mockDnsLookup.mockRejectedValue(new Error('ENOTFOUND'));
148
+ const result = await assertSafePublicUrl('https://nope.example/');
149
+ expect(result.ok).toBe(false);
150
+ if (!result.ok) expect(result.reason).toBe('dns resolution failed');
151
+ });
152
+
153
+ it('allows a public hostname resolving to public IPs and pins the first record', async () => {
154
+ mockDnsLookup.mockResolvedValue([
155
+ { address: '93.184.216.34', family: 4 },
156
+ { address: '93.184.216.35', family: 4 },
157
+ ]);
158
+ const result = await assertSafePublicUrl('https://example.com/');
159
+ expect(result.ok).toBe(true);
160
+ if (result.ok) {
161
+ expect(result.ip).toBe('93.184.216.34');
162
+ expect(result.family).toBe(4);
163
+ }
164
+ });
165
+ });
166
+
167
+ /**
168
+ * Bun {all:true} lookup gotcha — shape assertion.
169
+ *
170
+ * The pinned `lookup` in safeFetch MUST return `[{address,family}]` when called
171
+ * with `{ all: true }` (Bun calls lookup(host,{all:true},cb) then `.sort()`s the
172
+ * result — a single value makes that internal sort throw "results.sort is not a
173
+ * function"), and the `(err,address,family)` triple otherwise (Node). We
174
+ * reconstruct the exact closure shape here and assert both call modes.
175
+ *
176
+ * (A full real-https.request verification against Bun was performed out of band;
177
+ * this unit test guards the array-vs-triple contract under Jest/Node.)
178
+ */
179
+ describe('@oxyhq/core/server safeFetch — pinned lookup {all:true} contract', () => {
180
+ function makePinnedLookup(pinnedIp: string, pinnedFamily: 4 | 6): LookupFunction {
181
+ return ((
182
+ _hostname: string,
183
+ options: number | LookupOneOptions | LookupAllOptions,
184
+ callback: (
185
+ err: NodeJS.ErrnoException | null,
186
+ address: string | LookupAddress[],
187
+ family?: number,
188
+ ) => void,
189
+ ): void => {
190
+ const wantsAll = typeof options === 'object' && options !== null && options.all === true;
191
+ if (wantsAll) {
192
+ callback(null, [{ address: pinnedIp, family: pinnedFamily }]);
193
+ } else {
194
+ callback(null, pinnedIp, pinnedFamily);
195
+ }
196
+ }) as unknown as LookupFunction;
197
+ }
198
+
199
+ it('returns an ARRAY of {address,family} for {all:true} (sortable, no throw)', (done) => {
200
+ const lookup = makePinnedLookup('93.184.216.34', 4);
201
+ (lookup as unknown as (
202
+ h: string,
203
+ o: LookupAllOptions,
204
+ cb: (e: NodeJS.ErrnoException | null, a: LookupAddress[]) => void,
205
+ ) => void)(
206
+ 'example.com',
207
+ { all: true } as LookupAllOptions,
208
+ (err, address) => {
209
+ expect(err).toBeNull();
210
+ expect(Array.isArray(address)).toBe(true);
211
+ // The address array is what Bun internally `.sort()`s — must be sortable.
212
+ expect(() => (address as LookupAddress[]).sort()).not.toThrow();
213
+ expect(address).toEqual([{ address: '93.184.216.34', family: 4 }]);
214
+ done();
215
+ },
216
+ );
217
+ });
218
+
219
+ it('returns the (address,family) triple for the non-all (Node) form', (done) => {
220
+ const lookup = makePinnedLookup('93.184.216.34', 4);
221
+ (lookup as unknown as (
222
+ h: string,
223
+ o: LookupOneOptions,
224
+ cb: (e: NodeJS.ErrnoException | null, a: string, f: number) => void,
225
+ ) => void)('example.com', {} as LookupOneOptions, (err, address, family) => {
226
+ expect(err).toBeNull();
227
+ expect(address).toBe('93.184.216.34');
228
+ expect(family).toBe(4);
229
+ done();
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,40 @@
1
+ import { verifySecret } from '../verifySecret';
2
+
3
+ describe('@oxyhq/core/server verifySecret', () => {
4
+ it('returns true for equal secrets', () => {
5
+ expect(verifySecret('s3cr3t-token', 's3cr3t-token')).toBe(true);
6
+ expect(verifySecret('a', 'a')).toBe(true);
7
+ const long = 'x'.repeat(256);
8
+ expect(verifySecret(long, long)).toBe(true);
9
+ });
10
+
11
+ it('returns false for unequal same-length secrets', () => {
12
+ expect(verifySecret('s3cr3t-token', 's3cr3t-tokeN')).toBe(false);
13
+ expect(verifySecret('abcd', 'abce')).toBe(false);
14
+ });
15
+
16
+ it('returns false on length mismatch without throwing', () => {
17
+ expect(() => verifySecret('short', 'a-much-longer-secret')).not.toThrow();
18
+ expect(verifySecret('short', 'a-much-longer-secret')).toBe(false);
19
+ expect(verifySecret('a-much-longer-secret', 'short')).toBe(false);
20
+ expect(verifySecret('abc', 'abcd')).toBe(false);
21
+ });
22
+
23
+ it('returns false for empty inputs', () => {
24
+ expect(verifySecret('', '')).toBe(false);
25
+ expect(verifySecret('', 'x')).toBe(false);
26
+ expect(verifySecret('x', '')).toBe(false);
27
+ });
28
+
29
+ it('returns false for non-string inputs without throwing', () => {
30
+ expect(() => verifySecret(undefined as unknown as string, 'x')).not.toThrow();
31
+ expect(verifySecret(undefined as unknown as string, 'x')).toBe(false);
32
+ expect(verifySecret('x', null as unknown as string)).toBe(false);
33
+ expect(verifySecret(123 as unknown as string, 123 as unknown as string)).toBe(false);
34
+ });
35
+
36
+ it('handles multi-byte UTF-8 content correctly', () => {
37
+ expect(verifySecret('clé-secrète-🔐', 'clé-secrète-🔐')).toBe(true);
38
+ expect(verifySecret('clé-secrète-🔐', 'cle-secrete-🔐')).toBe(false);
39
+ });
40
+ });