@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,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.
|
|
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.
|
|
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
|
+
});
|