@oxyhq/core 3.9.1 → 3.10.1
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/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +2 -3
- package/dist/cjs/mixins/OxyServices.assets.js +29 -6
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- 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/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +29 -6
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- 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/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- 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/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/package.json +2 -1
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.assets.ts +40 -6
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +30 -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
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/fapiAutoDetect.ts +12 -39
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
|
|
27
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
28
|
+
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
29
|
+
import { registrableApex } from '../utils/fapiAutoDetect';
|
|
30
|
+
|
|
31
|
+
/** Default HTTP methods allowed across origins. */
|
|
32
|
+
const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
|
|
33
|
+
|
|
34
|
+
/** Default request headers a browser may send on a credentialed cross-origin call. */
|
|
35
|
+
const DEFAULT_ALLOWED_HEADERS = [
|
|
36
|
+
'Content-Type',
|
|
37
|
+
'Authorization',
|
|
38
|
+
'X-Requested-With',
|
|
39
|
+
'X-Oxy-User-Id',
|
|
40
|
+
'X-Oxy-Internal',
|
|
41
|
+
'X-CSRF-Token',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** How long (seconds) a browser may cache a successful preflight. */
|
|
45
|
+
const DEFAULT_MAX_AGE_SECONDS = 86_400;
|
|
46
|
+
|
|
47
|
+
export interface OxyCorsOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Explicit additional allowed origins (exact-origin match, e.g.
|
|
50
|
+
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
51
|
+
* ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
|
|
52
|
+
*/
|
|
53
|
+
appOrigins?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Whether to emit `Access-Control-Allow-Credentials: true`. Default `true`
|
|
56
|
+
* (the Oxy ecosystem uses cookie/bearer credentials). Even when `true`, the
|
|
57
|
+
* helper NEVER emits a wildcard origin — only an exact matched origin.
|
|
58
|
+
*/
|
|
59
|
+
allowCredentials?: boolean;
|
|
60
|
+
/** HTTP methods to allow. Defaults to the full standard set. */
|
|
61
|
+
methods?: string[];
|
|
62
|
+
/** Request headers to allow. Defaults to the common Oxy set. */
|
|
63
|
+
allowedHeaders?: string[];
|
|
64
|
+
/** Response headers to expose to the browser. Defaults to none. */
|
|
65
|
+
exposedHeaders?: string[];
|
|
66
|
+
/** Preflight cache lifetime in seconds. Default 86400 (24h). */
|
|
67
|
+
maxAgeSeconds?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `candidate` belongs to the Oxy apex origin family — i.e. its
|
|
72
|
+
* registrable apex equals {@link CENTRAL_IDP_APEX} (`oxy.so`). This matches the
|
|
73
|
+
* apex itself (`https://oxy.so`) and any subdomain (`https://auth.oxy.so`,
|
|
74
|
+
* `https://api.oxy.so`, …) over http or https, ports allowed. Returns false on
|
|
75
|
+
* any parse failure (fail closed).
|
|
76
|
+
*/
|
|
77
|
+
function isOxyFamilyOrigin(candidate: string): boolean {
|
|
78
|
+
let hostname: string;
|
|
79
|
+
let protocol: string;
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(candidate);
|
|
82
|
+
hostname = url.hostname.toLowerCase();
|
|
83
|
+
protocol = url.protocol;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (protocol !== 'https:' && protocol !== 'http:') return false;
|
|
88
|
+
if (hostname === CENTRAL_IDP_APEX) return true;
|
|
89
|
+
return registrableApex(hostname) === CENTRAL_IDP_APEX;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
|
|
93
|
+
function normalizeOrigin(raw: string): string | null {
|
|
94
|
+
try {
|
|
95
|
+
return new URL(raw).origin;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Build the origin-matching predicate: true iff `origin` is in the Oxy apex
|
|
103
|
+
* family OR exactly matches one of the configured app origins.
|
|
104
|
+
*/
|
|
105
|
+
function buildOriginAllowed(appOrigins: string[]): (origin: string) => boolean {
|
|
106
|
+
const explicit = new Set<string>();
|
|
107
|
+
for (const raw of appOrigins) {
|
|
108
|
+
const normalized = normalizeOrigin(raw);
|
|
109
|
+
if (normalized) explicit.add(normalized);
|
|
110
|
+
}
|
|
111
|
+
return (origin: string): boolean => {
|
|
112
|
+
const normalized = normalizeOrigin(origin);
|
|
113
|
+
if (normalized === null) return false;
|
|
114
|
+
if (explicit.has(normalized)) return true;
|
|
115
|
+
return isOxyFamilyOrigin(normalized);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Create a strict Oxy CORS middleware. See module docs.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* app.use(createOxyCors({ appOrigins: ['https://app.example.com'] }));
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function createOxyCors(options: OxyCorsOptions = {}): RequestHandler {
|
|
128
|
+
const {
|
|
129
|
+
appOrigins = [],
|
|
130
|
+
allowCredentials = true,
|
|
131
|
+
methods = DEFAULT_ALLOWED_METHODS,
|
|
132
|
+
allowedHeaders = DEFAULT_ALLOWED_HEADERS,
|
|
133
|
+
exposedHeaders = [],
|
|
134
|
+
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,
|
|
135
|
+
} = options;
|
|
136
|
+
|
|
137
|
+
const isOriginAllowed = buildOriginAllowed(appOrigins);
|
|
138
|
+
const methodsHeader = methods.join(', ');
|
|
139
|
+
const allowedHeadersHeader = allowedHeaders.join(', ');
|
|
140
|
+
const exposedHeadersHeader = exposedHeaders.join(', ');
|
|
141
|
+
|
|
142
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
143
|
+
const origin = req.headers.origin;
|
|
144
|
+
|
|
145
|
+
// Same-origin or non-browser requests carry no Origin header — pass through
|
|
146
|
+
// untouched (no ACAO header is emitted, which is correct for them).
|
|
147
|
+
if (typeof origin !== 'string' || origin.length === 0) {
|
|
148
|
+
if (req.method === 'OPTIONS') {
|
|
149
|
+
res.sendStatus(204);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
next();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Origin is present. Caching correctness: this response varies by Origin.
|
|
157
|
+
res.setHeader('Vary', 'Origin');
|
|
158
|
+
|
|
159
|
+
if (!isOriginAllowed(origin)) {
|
|
160
|
+
// DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
|
|
161
|
+
// will block the cross-origin read. Preflights for denied origins get a
|
|
162
|
+
// 204 with no CORS headers (the actual request then fails CORS).
|
|
163
|
+
if (req.method === 'OPTIONS') {
|
|
164
|
+
res.sendStatus(204);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
next();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ALLOW: echo the EXACT matched origin — never `*`, even without credentials.
|
|
172
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
173
|
+
if (allowCredentials) {
|
|
174
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
175
|
+
}
|
|
176
|
+
if (exposedHeadersHeader) {
|
|
177
|
+
res.setHeader('Access-Control-Expose-Headers', exposedHeadersHeader);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (req.method === 'OPTIONS') {
|
|
181
|
+
res.setHeader('Access-Control-Allow-Methods', methodsHeader);
|
|
182
|
+
// Honour the browser's requested headers when present, else the default set.
|
|
183
|
+
const requested = req.headers['access-control-request-headers'];
|
|
184
|
+
res.setHeader(
|
|
185
|
+
'Access-Control-Allow-Headers',
|
|
186
|
+
typeof requested === 'string' && requested.length > 0 ? requested : allowedHeadersHeader,
|
|
187
|
+
);
|
|
188
|
+
res.setHeader('Access-Control-Max-Age', String(maxAgeSeconds));
|
|
189
|
+
res.sendStatus(204);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
next();
|
|
194
|
+
};
|
|
195
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -34,3 +34,33 @@ export type {
|
|
|
34
34
|
} from './auth';
|
|
35
35
|
export { createOxyRateLimit } from './rateLimit';
|
|
36
36
|
export type { OxyRateLimitOptions } from './rateLimit';
|
|
37
|
+
|
|
38
|
+
// SSRF-safe upstream fetch + URL validation (Node-only).
|
|
39
|
+
export {
|
|
40
|
+
assertSafePublicUrl,
|
|
41
|
+
isBlockedIp,
|
|
42
|
+
safeFetch,
|
|
43
|
+
SsrfRejection,
|
|
44
|
+
UpstreamError,
|
|
45
|
+
ALLOWED_PORTS,
|
|
46
|
+
ALLOWED_PROTOCOLS,
|
|
47
|
+
BLOCKED_HOSTNAMES,
|
|
48
|
+
DEFAULT_USER_AGENT,
|
|
49
|
+
MAX_REDIRECTS,
|
|
50
|
+
MAX_URL_LENGTH,
|
|
51
|
+
UPSTREAM_HEADERS_TIMEOUT_MS,
|
|
52
|
+
} from './safeFetch';
|
|
53
|
+
export type {
|
|
54
|
+
SafeFetchOptions,
|
|
55
|
+
SafeFetchResult,
|
|
56
|
+
SsrfCheckFail,
|
|
57
|
+
SsrfCheckOk,
|
|
58
|
+
SsrfCheckResult,
|
|
59
|
+
} from './safeFetch';
|
|
60
|
+
|
|
61
|
+
// Strict CORS allowlist (Oxy apex family + explicit app origins).
|
|
62
|
+
export { createOxyCors } from './cors';
|
|
63
|
+
export type { OxyCorsOptions } from './cors';
|
|
64
|
+
|
|
65
|
+
// Constant-time secret comparison.
|
|
66
|
+
export { verifySecret } from './verifySecret';
|