@oxyhq/core 12.11.1 → 13.2.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/README.md +36 -2
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +4 -13
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +0 -31
- package/dist/cjs/mixins/OxyServices.user.js +50 -44
- package/dist/cjs/server/index.js +9 -6
- package/dist/cjs/server/rateLimit.js +3 -0
- package/dist/cjs/server/securityHeaders.js +234 -0
- package/dist/cjs/session/accountDialogController.js +6 -8
- package/dist/cjs/utils/apiUtils.js +40 -10
- package/dist/cjs/utils/oauthPkce.js +1 -5
- package/dist/cjs/utils/officialOrigins.js +3 -73
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +3 -4
- package/dist/esm/mixins/OxyServices.deviceBoot.js +1 -32
- package/dist/esm/mixins/OxyServices.user.js +51 -45
- package/dist/esm/server/index.js +4 -1
- package/dist/esm/server/rateLimit.js +3 -0
- package/dist/esm/server/securityHeaders.js +224 -0
- package/dist/esm/session/accountDialogController.js +6 -8
- package/dist/esm/utils/apiUtils.js +39 -10
- package/dist/esm/utils/oauthPkce.js +0 -4
- package/dist/esm/utils/officialOrigins.js +3 -68
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +5 -7
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -12
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +1 -5
- package/dist/types/mixins/OxyServices.user.d.ts +27 -6
- package/dist/types/server/index.d.ts +3 -1
- package/dist/types/server/securityHeaders.d.ts +154 -0
- package/dist/types/session/accountDialogController.d.ts +9 -15
- package/dist/types/utils/apiUtils.d.ts +48 -6
- package/dist/types/utils/oauthPkce.d.ts +11 -7
- package/dist/types/utils/officialOrigins.d.ts +3 -13
- package/package.json +10 -5
- package/src/index.ts +9 -14
- package/src/mixins/OxyServices.auth.ts +6 -13
- package/src/mixins/OxyServices.deviceBoot.ts +0 -47
- package/src/mixins/OxyServices.user.ts +60 -49
- package/src/mixins/__tests__/commonsSignIn.test.ts +9 -2
- package/src/mixins/__tests__/followGraphPagination.test.ts +250 -0
- package/src/server/__tests__/securityHeaders.test.ts +244 -0
- package/src/server/index.ts +17 -8
- package/src/server/rateLimit.ts +3 -0
- package/src/server/securityHeaders.ts +304 -0
- package/src/session/__tests__/accountDialogController.test.ts +3 -5
- package/src/session/accountDialogController.ts +12 -18
- package/src/utils/__tests__/officialOrigins.test.ts +0 -57
- package/src/utils/apiUtils.ts +64 -15
- package/src/utils/oauthPkce.ts +11 -9
- package/src/utils/officialOrigins.ts +3 -70
- package/dist/cjs/session/hubSync.js +0 -55
- package/dist/esm/session/hubSync.js +0 -51
- package/dist/types/session/hubSync.d.ts +0 -20
- package/src/session/__tests__/hubSync.test.ts +0 -51
- package/src/session/hubSync.ts +0 -79
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
buildOxyCspDirectives,
|
|
4
|
+
buildOxyPagesHeaders,
|
|
5
|
+
createOxySecurityHeaders,
|
|
6
|
+
formatOxyCspPolicy,
|
|
7
|
+
OXY_CSP_BASELINE,
|
|
8
|
+
type OxyCspExtensions,
|
|
9
|
+
} from '../securityHeaders';
|
|
10
|
+
|
|
11
|
+
const CLOUDFLARE_SCRIPT_HOST = 'https://static.cloudflareinsights.com';
|
|
12
|
+
const CLOUDFLARE_REPORT_HOST = 'https://cloudflareinsights.com';
|
|
13
|
+
|
|
14
|
+
/** Run the middleware and return the CSP header exactly as a browser would see it. */
|
|
15
|
+
function renderPolicy(options: Parameters<typeof createOxySecurityHeaders>[0]): string {
|
|
16
|
+
const headers: Record<string, string> = {};
|
|
17
|
+
const res = {
|
|
18
|
+
setHeader: (name: string, value: string | number | readonly string[]): void => {
|
|
19
|
+
headers[name] = String(value);
|
|
20
|
+
},
|
|
21
|
+
removeHeader: (): void => undefined,
|
|
22
|
+
} as unknown as Response;
|
|
23
|
+
const req = { method: 'GET', headers: {} } as unknown as Request;
|
|
24
|
+
const next = jest.fn() as unknown as NextFunction;
|
|
25
|
+
|
|
26
|
+
createOxySecurityHeaders(options)(req, res, next);
|
|
27
|
+
return headers['Content-Security-Policy'] ?? '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The sources of one directive, parsed back out of the rendered header. */
|
|
31
|
+
function policySources(policy: string, directive: string): string[] {
|
|
32
|
+
const found = policy
|
|
33
|
+
.split(';')
|
|
34
|
+
.map((segment) => segment.trim())
|
|
35
|
+
.find((segment) => segment === directive || segment.startsWith(`${directive} `));
|
|
36
|
+
if (found === undefined) return [];
|
|
37
|
+
return found.split(/\s+/).slice(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('@oxyhq/core/server buildOxyCspDirectives', () => {
|
|
41
|
+
it('carries the Cloudflare Insights beacon on BOTH halves of the baseline', () => {
|
|
42
|
+
// The bug this helper exists for: the script host alone leaves the beacon
|
|
43
|
+
// loading but unable to report, which looks fixed and is not.
|
|
44
|
+
const directives = buildOxyCspDirectives();
|
|
45
|
+
expect(directives['script-src']).toContain(CLOUDFLARE_SCRIPT_HOST);
|
|
46
|
+
expect(directives['connect-src']).toContain(CLOUDFLARE_REPORT_HOST);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('carries the Oxy platform origins every app reaches through the SDK', () => {
|
|
50
|
+
const directives = buildOxyCspDirectives();
|
|
51
|
+
expect(directives['connect-src']).toEqual(
|
|
52
|
+
expect.arrayContaining(['https://api.oxy.so', 'wss://api.oxy.so', 'https://cloud.oxy.so']),
|
|
53
|
+
);
|
|
54
|
+
expect(directives['img-src']).toContain('https://cloud.oxy.so');
|
|
55
|
+
expect(directives['media-src']).toContain('https://cloud.oxy.so');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('emits the hardening floor: closed object-src/script-src-attr and upgrade-insecure-requests', () => {
|
|
59
|
+
const directives = buildOxyCspDirectives();
|
|
60
|
+
expect(directives['object-src']).toEqual(["'none'"]);
|
|
61
|
+
expect(directives['script-src-attr']).toEqual(["'none'"]);
|
|
62
|
+
expect(directives['frame-ancestors']).toEqual(["'none'"]);
|
|
63
|
+
expect(directives['upgrade-insecure-requests']).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('MERGES an extension into the baseline instead of replacing it', () => {
|
|
67
|
+
const directives = buildOxyCspDirectives({
|
|
68
|
+
scriptSrc: ['https://app.example.com'],
|
|
69
|
+
connectSrc: ['wss://api.example.com'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// The extension is present…
|
|
73
|
+
expect(directives['script-src']).toContain('https://app.example.com');
|
|
74
|
+
expect(directives['connect-src']).toContain('wss://api.example.com');
|
|
75
|
+
// …and every baseline source SURVIVED it.
|
|
76
|
+
for (const source of OXY_CSP_BASELINE.scriptSrc ?? []) {
|
|
77
|
+
expect(directives['script-src']).toContain(source);
|
|
78
|
+
}
|
|
79
|
+
for (const source of OXY_CSP_BASELINE.connectSrc ?? []) {
|
|
80
|
+
expect(directives['connect-src']).toContain(source);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("retains 'self' in every extended directive, including directives absent from the baseline", () => {
|
|
85
|
+
// The footgun: an explicit `script-src` replaces helmet's default and drops
|
|
86
|
+
// `'self'`, so the app's own bundle stops loading.
|
|
87
|
+
const extensions: OxyCspExtensions = {
|
|
88
|
+
scriptSrc: ['https://app.example.com'],
|
|
89
|
+
connectSrc: ['https://api.example.com'],
|
|
90
|
+
imgSrc: ['blob:'],
|
|
91
|
+
mediaSrc: ['blob:'],
|
|
92
|
+
styleSrc: ['https://fonts.example.com'],
|
|
93
|
+
fontSrc: ['https://fonts.example.com'],
|
|
94
|
+
// Not in the baseline at all — must still be seeded with 'self'.
|
|
95
|
+
frameSrc: ['https://player.vimeo.com'],
|
|
96
|
+
workerSrc: ['blob:'],
|
|
97
|
+
manifestSrc: ['https://cdn.example.com'],
|
|
98
|
+
scriptSrcElem: ['https://app.example.com'],
|
|
99
|
+
styleSrcElem: ['https://cdn.example.com'],
|
|
100
|
+
};
|
|
101
|
+
const directives = buildOxyCspDirectives(extensions);
|
|
102
|
+
|
|
103
|
+
for (const directive of Object.keys(extensions)) {
|
|
104
|
+
const headerName = directive.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
105
|
+
expect(directives[headerName]).toContain("'self'");
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('dedupes sources a caller repeats or that already exist in the baseline', () => {
|
|
110
|
+
const directives = buildOxyCspDirectives({
|
|
111
|
+
scriptSrc: ["'self'", CLOUDFLARE_SCRIPT_HOST, 'https://app.example.com'],
|
|
112
|
+
frameSrc: ["'self'", 'https://player.vimeo.com', 'https://player.vimeo.com'],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(directives['script-src']).toEqual([
|
|
116
|
+
"'self'",
|
|
117
|
+
CLOUDFLARE_SCRIPT_HOST,
|
|
118
|
+
'https://app.example.com',
|
|
119
|
+
]);
|
|
120
|
+
expect(directives['frame-src']).toEqual(["'self'", 'https://player.vimeo.com']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("drops the 'none' sentinel when a closed directive is opened, and keeps it otherwise", () => {
|
|
124
|
+
// 'none' alongside any other source is meaningless per the CSP spec, so an
|
|
125
|
+
// app that must be framable opts back in explicitly.
|
|
126
|
+
expect(buildOxyCspDirectives({ frameAncestors: ["'self'"] })['frame-ancestors']).toEqual([
|
|
127
|
+
"'self'",
|
|
128
|
+
]);
|
|
129
|
+
expect(buildOxyCspDirectives({ objectSrc: [] })['object-src']).toEqual(["'none'"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('rejects sources that would terminate the directive or the policy', () => {
|
|
133
|
+
expect(() => buildOxyCspDirectives({ scriptSrc: ["https://a.example.com; script-src 'unsafe-inline'"] })).toThrow(
|
|
134
|
+
/script-src/,
|
|
135
|
+
);
|
|
136
|
+
expect(() => buildOxyCspDirectives({ connectSrc: ['https://a.example.com,https://b.example.com'] })).toThrow(
|
|
137
|
+
/connect-src/,
|
|
138
|
+
);
|
|
139
|
+
expect(() => buildOxyCspDirectives({ imgSrc: [''] })).toThrow(/img-src/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('never mutates the exported baseline across calls', () => {
|
|
143
|
+
const before = [...(OXY_CSP_BASELINE.scriptSrc ?? [])];
|
|
144
|
+
buildOxyCspDirectives({ scriptSrc: ['https://app.example.com'] });
|
|
145
|
+
buildOxyCspDirectives({ scriptSrc: ['https://other.example.com'] });
|
|
146
|
+
expect([...(OXY_CSP_BASELINE.scriptSrc ?? [])]).toEqual(before);
|
|
147
|
+
expect(buildOxyCspDirectives()['script-src']).toEqual(before);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('@oxyhq/core/server formatOxyCspPolicy', () => {
|
|
152
|
+
it('serializes directives into a single CSP header value', () => {
|
|
153
|
+
const policy = formatOxyCspPolicy(buildOxyCspDirectives());
|
|
154
|
+
expect(policy).toContain("script-src 'self' https://static.cloudflareinsights.com");
|
|
155
|
+
expect(policy).toContain('upgrade-insecure-requests');
|
|
156
|
+
expect(policy.endsWith('upgrade-insecure-requests')).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('@oxyhq/core/server buildOxyPagesHeaders', () => {
|
|
161
|
+
it('emits a Cloudflare Pages _headers block with CSP and hardening headers', () => {
|
|
162
|
+
const block = buildOxyPagesHeaders();
|
|
163
|
+
expect(block.startsWith('/*\n')).toBe(true);
|
|
164
|
+
expect(block).toContain('Content-Security-Policy:');
|
|
165
|
+
expect(block).toContain(CLOUDFLARE_SCRIPT_HOST);
|
|
166
|
+
expect(block).toContain(CLOUDFLARE_REPORT_HOST);
|
|
167
|
+
expect(block).toContain('X-Frame-Options: DENY');
|
|
168
|
+
expect(block).toContain('Strict-Transport-Security:');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('merges per-app CSP extensions into the deployed header', () => {
|
|
172
|
+
const block = buildOxyPagesHeaders({
|
|
173
|
+
csp: { workerSrc: ["'self'"], imgSrc: ['blob:', 'https:'] },
|
|
174
|
+
});
|
|
175
|
+
expect(block).toContain("worker-src 'self'");
|
|
176
|
+
expect(block).toContain('blob:');
|
|
177
|
+
expect(block).toContain('https:');
|
|
178
|
+
expect(block).toContain(CLOUDFLARE_SCRIPT_HOST);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
183
|
+
it('sends the resolved baseline as a real Content-Security-Policy header', () => {
|
|
184
|
+
const policy = renderPolicy({});
|
|
185
|
+
|
|
186
|
+
expect(policySources(policy, 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
|
|
187
|
+
expect(policySources(policy, 'connect-src')).toEqual([
|
|
188
|
+
"'self'",
|
|
189
|
+
CLOUDFLARE_REPORT_HOST,
|
|
190
|
+
'https://api.oxy.so',
|
|
191
|
+
'wss://api.oxy.so',
|
|
192
|
+
'https://cloud.oxy.so',
|
|
193
|
+
]);
|
|
194
|
+
expect(policy).toContain('upgrade-insecure-requests');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('sends merged app extensions without losing the baseline', () => {
|
|
198
|
+
const policy = renderPolicy({
|
|
199
|
+
csp: {
|
|
200
|
+
connectSrc: ['https://api.mention.earth', 'wss://api.mention.earth'],
|
|
201
|
+
frameSrc: ['https://www.youtube-nocookie.com'],
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(policySources(policy, 'connect-src')).toEqual([
|
|
206
|
+
"'self'",
|
|
207
|
+
CLOUDFLARE_REPORT_HOST,
|
|
208
|
+
'https://api.oxy.so',
|
|
209
|
+
'wss://api.oxy.so',
|
|
210
|
+
'https://cloud.oxy.so',
|
|
211
|
+
'https://api.mention.earth',
|
|
212
|
+
'wss://api.mention.earth',
|
|
213
|
+
]);
|
|
214
|
+
expect(policySources(policy, 'frame-src')).toEqual([
|
|
215
|
+
"'self'",
|
|
216
|
+
'https://www.youtube-nocookie.com',
|
|
217
|
+
]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('passes non-CSP helmet options through and calls next()', () => {
|
|
221
|
+
const headers: Record<string, string> = {};
|
|
222
|
+
const res = {
|
|
223
|
+
setHeader: (name: string, value: string | number | readonly string[]): void => {
|
|
224
|
+
headers[name] = String(value);
|
|
225
|
+
},
|
|
226
|
+
removeHeader: (): void => undefined,
|
|
227
|
+
} as unknown as Response;
|
|
228
|
+
const next = jest.fn() as unknown as NextFunction & jest.Mock;
|
|
229
|
+
|
|
230
|
+
createOxySecurityHeaders({
|
|
231
|
+
helmet: {
|
|
232
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
233
|
+
frameguard: { action: 'deny' },
|
|
234
|
+
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
|
|
235
|
+
},
|
|
236
|
+
})({ method: 'GET', headers: {} } as unknown as Request, res, next);
|
|
237
|
+
|
|
238
|
+
expect(headers['Cross-Origin-Resource-Policy']).toBe('cross-origin');
|
|
239
|
+
expect(headers['X-Frame-Options']).toBe('DENY');
|
|
240
|
+
expect(headers['Referrer-Policy']).toBe('strict-origin-when-cross-origin');
|
|
241
|
+
expect(headers['Content-Security-Policy']).toContain("script-src 'self'");
|
|
242
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -63,6 +63,22 @@ export type {
|
|
|
63
63
|
export { createOxyCors } from './cors';
|
|
64
64
|
export type { OxyCorsOptions } from './cors';
|
|
65
65
|
|
|
66
|
+
// Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
|
|
67
|
+
// Oxy API/CDN origins) with additive, per-app extensions.
|
|
68
|
+
export {
|
|
69
|
+
buildOxyCspDirectives,
|
|
70
|
+
buildOxyPagesHeaders,
|
|
71
|
+
createOxySecurityHeaders,
|
|
72
|
+
formatOxyCspPolicy,
|
|
73
|
+
OXY_CSP_BASELINE,
|
|
74
|
+
} from './securityHeaders';
|
|
75
|
+
export type {
|
|
76
|
+
OxyCspDirective,
|
|
77
|
+
OxyCspExtensions,
|
|
78
|
+
OxyPagesHeadersOptions,
|
|
79
|
+
OxySecurityHeadersOptions,
|
|
80
|
+
} from './securityHeaders';
|
|
81
|
+
|
|
66
82
|
// Constant-time secret comparison.
|
|
67
83
|
export { verifySecret } from './verifySecret';
|
|
68
84
|
|
|
@@ -71,11 +87,4 @@ export { verifySecret } from './verifySecret';
|
|
|
71
87
|
// Pure host handling (no browser deps), so it is safe on the server subpath and
|
|
72
88
|
// lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
|
|
73
89
|
export { registrableApex } from '../utils/registrableApex';
|
|
74
|
-
export {
|
|
75
|
-
buildIdpHubOrigin,
|
|
76
|
-
buildHubSyncUrl,
|
|
77
|
-
isIdpHubOrigin,
|
|
78
|
-
isOfficialWebOrigin,
|
|
79
|
-
normalizeOfficialReturnOrigin,
|
|
80
|
-
parseHubSyncReturnUrl,
|
|
81
|
-
} from '../utils/officialOrigins';
|
|
90
|
+
export { isOfficialWebOrigin } from '../utils/officialOrigins';
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -278,6 +278,9 @@ export function createOxyRateLimit(
|
|
|
278
278
|
standardHeaders: true,
|
|
279
279
|
legacyHeaders: false,
|
|
280
280
|
skip,
|
|
281
|
+
// hashAnonymousIp already buckets IPv6 to /56 before HMAC; disable the v8
|
|
282
|
+
// static source scan that false-positives on req.ip (ERR_ERL_KEY_GEN_IPV6).
|
|
283
|
+
validate: { keyGeneratorIpFallback: false },
|
|
281
284
|
});
|
|
282
285
|
|
|
283
286
|
return (req, res, next) => {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared security headers (Helmet + Content-Security-Policy) for Oxy backends.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* A CSP only governs an origin that serves DOCUMENTS; on a JSON API it governs
|
|
7
|
+
* no browsing context. The Oxy origins that serve HTML through Cloudflare have
|
|
8
|
+
* so far either hand-written their own policy or shipped none at all, and two
|
|
9
|
+
* bugs follow from that:
|
|
10
|
+
*
|
|
11
|
+
* 1. THE CLOUDFLARE INSIGHTS BEACON IS BLOCKED BY A HAND-WRITTEN POLICY.
|
|
12
|
+
* Cloudflare injects `<script src="https://static.cloudflareinsights.com/
|
|
13
|
+
* beacon.min.js/...">` into HTML it proxies. No application code loads it,
|
|
14
|
+
* so it cannot be allowlisted from the app side any other way, and an
|
|
15
|
+
* origin whose policy says `script-src 'self'` logs
|
|
16
|
+
* `Loading the script 'https://static.cloudflareinsights.com/beacon.min.js'
|
|
17
|
+
* violates the following Content Security Policy directive: "script-src
|
|
18
|
+
* 'self'"` and collects nothing. The beacon needs BOTH hosts, and they are
|
|
19
|
+
* different halves of the same feature: `static.cloudflareinsights.com`
|
|
20
|
+
* serves the script (`script-src`), `cloudflareinsights.com` receives the
|
|
21
|
+
* measurements (`connect-src`). Allowing only the script leaves the beacon
|
|
22
|
+
* loading but unable to report, which looks fixed and is not. Verified in
|
|
23
|
+
* production 2026-07-29: `mention.earth` serves HTML behind Cloudflare with
|
|
24
|
+
* the beacon injected and `script-src 'self'` — blocked; `oxy.so` had
|
|
25
|
+
* already allowlisted the same two hosts in its own static `_headers`,
|
|
26
|
+
* independently, which is the divergence this baseline exists to end.
|
|
27
|
+
*
|
|
28
|
+
* 2. AN EXPLICIT DIRECTIVE SILENTLY REPLACES HELMET'S DEFAULT.
|
|
29
|
+
* Writing `scriptSrc: ['https://example.com']` drops `'self'` — the page's
|
|
30
|
+
* own bundle stops loading (or, worse, only some lazily-loaded chunk does,
|
|
31
|
+
* so it ships). This helper makes that structurally impossible: callers can
|
|
32
|
+
* only ADD sources to the Oxy baseline, never replace a directive, and they
|
|
33
|
+
* cannot pass their own `contentSecurityPolicy` through to Helmet at all
|
|
34
|
+
* (the option is typed `never`).
|
|
35
|
+
*
|
|
36
|
+
* WHAT IT PROVIDES
|
|
37
|
+
* ----------------
|
|
38
|
+
* `createOxySecurityHeaders(options)` returns the Helmet middleware with the
|
|
39
|
+
* Oxy-wide CSP baseline applied, plus per-app extensions merged (and deduped)
|
|
40
|
+
* into it. Everything Helmet does that is NOT the CSP (HSTS, frameguard,
|
|
41
|
+
* referrer policy, CORP/COOP, …) is passed straight through, so an app keeps
|
|
42
|
+
* full control of those.
|
|
43
|
+
*
|
|
44
|
+
* `buildOxyCspDirectives(extensions)` is the same resolution as a pure
|
|
45
|
+
* function, for the Oxy document origins that are NOT Express — a Next.js
|
|
46
|
+
* `headers()`, a Cloudflare Pages `_headers` generator — so one policy can
|
|
47
|
+
* cover them without a second implementation.
|
|
48
|
+
*
|
|
49
|
+
* SCOPE: mount this on backends that serve HTML. A JSON-only API gains nothing
|
|
50
|
+
* from a source-list CSP; harden those with the non-CSP headers instead
|
|
51
|
+
* (`hsts`, `noSniff`, `frameguard`, CORP) rather than adding directives that
|
|
52
|
+
* apply to no document.
|
|
53
|
+
*
|
|
54
|
+
* Node/Express-only: exported solely from `@oxyhq/core/server`.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
import type { RequestHandler } from 'express';
|
|
58
|
+
import helmet, { type HelmetOptions } from 'helmet';
|
|
59
|
+
|
|
60
|
+
/** CSP keyword for "this origin". Always present in every open baseline directive. */
|
|
61
|
+
const SELF = "'self'";
|
|
62
|
+
|
|
63
|
+
/** CSP keyword for a fully closed directive. Meaningless alongside any other source. */
|
|
64
|
+
const NONE = "'none'";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cloudflare Web Analytics. Injected at the edge into proxied HTML — no Oxy app
|
|
68
|
+
* loads it, and no Oxy app should have to know these hostnames. Both are
|
|
69
|
+
* required: the script host, and the host the beacon reports to.
|
|
70
|
+
*/
|
|
71
|
+
const CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN = 'https://static.cloudflareinsights.com';
|
|
72
|
+
const CLOUDFLARE_INSIGHTS_REPORT_ORIGIN = 'https://cloudflareinsights.com';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Oxy platform origins. Every Oxy web origin runs the SDK, which calls the Oxy
|
|
76
|
+
* API over HTTPS and Socket.IO, and resolves all canonical media through the
|
|
77
|
+
* Oxy CDN (`getFileDownloadUrl` → `cloud.oxy.so`).
|
|
78
|
+
*/
|
|
79
|
+
const OXY_API_ORIGIN = 'https://api.oxy.so';
|
|
80
|
+
const OXY_API_WEBSOCKET_ORIGIN = 'wss://api.oxy.so';
|
|
81
|
+
const OXY_CDN_ORIGIN = 'https://cloud.oxy.so';
|
|
82
|
+
|
|
83
|
+
/** The CSP directives an Oxy app may extend, in Helmet's camelCase spelling. */
|
|
84
|
+
export type OxyCspDirective =
|
|
85
|
+
| 'baseUri'
|
|
86
|
+
| 'connectSrc'
|
|
87
|
+
| 'defaultSrc'
|
|
88
|
+
| 'fontSrc'
|
|
89
|
+
| 'formAction'
|
|
90
|
+
| 'frameAncestors'
|
|
91
|
+
| 'frameSrc'
|
|
92
|
+
| 'imgSrc'
|
|
93
|
+
| 'manifestSrc'
|
|
94
|
+
| 'mediaSrc'
|
|
95
|
+
| 'objectSrc'
|
|
96
|
+
| 'scriptSrc'
|
|
97
|
+
| 'scriptSrcAttr'
|
|
98
|
+
| 'scriptSrcElem'
|
|
99
|
+
| 'styleSrc'
|
|
100
|
+
| 'styleSrcElem'
|
|
101
|
+
| 'workerSrc';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Per-app ADDITIONS to the Oxy baseline, keyed by directive. Values are merged
|
|
105
|
+
* into the baseline and deduped — they never replace it, so `'self'` (and the
|
|
106
|
+
* Cloudflare beacon hosts) cannot be lost. Extending a directive the baseline
|
|
107
|
+
* does not define seeds it with `'self'` first, for the same reason.
|
|
108
|
+
*/
|
|
109
|
+
export type OxyCspExtensions = Partial<Record<OxyCspDirective, readonly string[]>>;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The Oxy-wide CSP baseline. Deliberately the floor every Oxy origin needs, not
|
|
113
|
+
* a superset of what any one app allows — permissive sources an individual app
|
|
114
|
+
* wants (`https:` images, `blob:` media, embed hosts, LiveKit) are that app's
|
|
115
|
+
* extension, so each widening stays visible at its call site.
|
|
116
|
+
*
|
|
117
|
+
* `style-src` carries `'unsafe-inline'` because react-native-web injects its
|
|
118
|
+
* stylesheet as inline `<style>` at runtime; without it every Oxy web app
|
|
119
|
+
* renders unstyled.
|
|
120
|
+
*/
|
|
121
|
+
export const OXY_CSP_BASELINE: Readonly<Partial<Record<OxyCspDirective, readonly string[]>>> =
|
|
122
|
+
Object.freeze({
|
|
123
|
+
defaultSrc: Object.freeze([SELF]),
|
|
124
|
+
baseUri: Object.freeze([SELF]),
|
|
125
|
+
formAction: Object.freeze([SELF]),
|
|
126
|
+
frameAncestors: Object.freeze([NONE]),
|
|
127
|
+
objectSrc: Object.freeze([NONE]),
|
|
128
|
+
scriptSrc: Object.freeze([SELF, CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN]),
|
|
129
|
+
scriptSrcAttr: Object.freeze([NONE]),
|
|
130
|
+
styleSrc: Object.freeze([SELF, "'unsafe-inline'"]),
|
|
131
|
+
imgSrc: Object.freeze([SELF, 'data:', OXY_CDN_ORIGIN]),
|
|
132
|
+
mediaSrc: Object.freeze([SELF, OXY_CDN_ORIGIN]),
|
|
133
|
+
fontSrc: Object.freeze([SELF, 'data:']),
|
|
134
|
+
connectSrc: Object.freeze([
|
|
135
|
+
SELF,
|
|
136
|
+
CLOUDFLARE_INSIGHTS_REPORT_ORIGIN,
|
|
137
|
+
OXY_API_ORIGIN,
|
|
138
|
+
OXY_API_WEBSOCKET_ORIGIN,
|
|
139
|
+
OXY_CDN_ORIGIN,
|
|
140
|
+
]),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** `connectSrc` → `connect-src`. Total over `OxyCspDirective` (all are camelCase ASCII). */
|
|
144
|
+
function toHeaderDirectiveName(directive: OxyCspDirective): string {
|
|
145
|
+
return directive.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A source containing `;` or `,` would silently terminate the directive (or the
|
|
150
|
+
* whole policy) and hand the rest of the string to the browser as new
|
|
151
|
+
* directives. Helmet rejects these too; we reject them here so the pure builder
|
|
152
|
+
* is equally safe, and so the failure names the offending directive.
|
|
153
|
+
*/
|
|
154
|
+
function assertValidSource(directive: OxyCspDirective, source: string): void {
|
|
155
|
+
if (typeof source !== 'string' || source.length === 0) {
|
|
156
|
+
throw new TypeError(`Oxy CSP: ${toHeaderDirectiveName(directive)} received an empty source.`);
|
|
157
|
+
}
|
|
158
|
+
if (source.includes(';') || source.includes(',')) {
|
|
159
|
+
throw new TypeError(
|
|
160
|
+
`Oxy CSP: ${toHeaderDirectiveName(directive)} source ${JSON.stringify(source)} may not contain ";" or ",".`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Order-preserving, first-seen-wins dedupe. */
|
|
166
|
+
function dedupe(sources: readonly string[]): string[] {
|
|
167
|
+
return [...new Set(sources)];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Resolve the effective CSP directives: the Oxy baseline, with each app
|
|
172
|
+
* extension merged in and deduped.
|
|
173
|
+
*
|
|
174
|
+
* Merge rules:
|
|
175
|
+
* - A baseline directive is EXTENDED, never replaced — `'self'` and the
|
|
176
|
+
* Cloudflare beacon hosts always survive.
|
|
177
|
+
* - A directive absent from the baseline is seeded with `'self'`, so adding
|
|
178
|
+
* (say) an embed host to `frame-src` cannot lock the origin out of itself.
|
|
179
|
+
* - A directive whose baseline is exactly `'none'` is CLOSED: extending it
|
|
180
|
+
* drops the sentinel, because `'none'` alongside any other source is
|
|
181
|
+
* meaningless per the CSP spec. This is how an app that must be framable
|
|
182
|
+
* opts back in with `frameAncestors: ["'self'"]`.
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```ts
|
|
186
|
+
* buildOxyCspDirectives({ frameSrc: ['https://player.vimeo.com'] });
|
|
187
|
+
* // → { ..., 'frame-src': ["'self'", 'https://player.vimeo.com'], ... }
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export function buildOxyCspDirectives(extensions: OxyCspExtensions = {}): Record<string, string[]> {
|
|
191
|
+
const directiveNames = new Set<OxyCspDirective>([
|
|
192
|
+
...(Object.keys(OXY_CSP_BASELINE) as OxyCspDirective[]),
|
|
193
|
+
...(Object.keys(extensions) as OxyCspDirective[]),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
const resolved: Record<string, string[]> = {};
|
|
197
|
+
|
|
198
|
+
for (const directive of directiveNames) {
|
|
199
|
+
const extras = extensions[directive] ?? [];
|
|
200
|
+
for (const source of extras) {
|
|
201
|
+
assertValidSource(directive, source);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const baseline = OXY_CSP_BASELINE[directive] ?? [SELF];
|
|
205
|
+
const isClosed = baseline.length === 1 && baseline[0] === NONE;
|
|
206
|
+
const merged = isClosed && extras.length > 0 ? extras : [...baseline, ...extras];
|
|
207
|
+
|
|
208
|
+
resolved[toHeaderDirectiveName(directive)] = dedupe(merged);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Valueless directive: rewrite stray `http://` subresources to HTTPS rather
|
|
212
|
+
// than failing them, which matters for federated/user-supplied URLs.
|
|
213
|
+
resolved['upgrade-insecure-requests'] = [];
|
|
214
|
+
|
|
215
|
+
return resolved;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Serialize resolved CSP directives into the single-line header value browsers
|
|
220
|
+
* and Cloudflare `_headers` expect. Valueless directives (e.g.
|
|
221
|
+
* `upgrade-insecure-requests`) emit the name alone.
|
|
222
|
+
*/
|
|
223
|
+
export function formatOxyCspPolicy(directives: Record<string, string[]>): string {
|
|
224
|
+
return Object.entries(directives)
|
|
225
|
+
.map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
|
|
226
|
+
.join('; ');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface OxyPagesHeadersOptions {
|
|
230
|
+
/** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
|
|
231
|
+
csp?: OxyCspExtensions;
|
|
232
|
+
/**
|
|
233
|
+
* Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
|
|
234
|
+
* HTTPS only, so static deploys should keep this on.
|
|
235
|
+
*/
|
|
236
|
+
hsts?: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
|
|
241
|
+
* same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
|
|
242
|
+
* hardening headers Helmet would add on an Express HTML backend.
|
|
243
|
+
*/
|
|
244
|
+
export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): string {
|
|
245
|
+
const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
|
|
246
|
+
const lines = [
|
|
247
|
+
'/*',
|
|
248
|
+
` Content-Security-Policy: ${csp}`,
|
|
249
|
+
' X-Frame-Options: DENY',
|
|
250
|
+
' X-Content-Type-Options: nosniff',
|
|
251
|
+
' Referrer-Policy: strict-origin-when-cross-origin',
|
|
252
|
+
];
|
|
253
|
+
if (options.hsts !== false) {
|
|
254
|
+
lines.push(' Strict-Transport-Security: max-age=31536000; includeSubDomains; preload');
|
|
255
|
+
}
|
|
256
|
+
lines.push('');
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface OxySecurityHeadersOptions {
|
|
261
|
+
/**
|
|
262
|
+
* Per-app additions to the Oxy CSP baseline. Merged, deduped, never
|
|
263
|
+
* replacing — see {@link buildOxyCspDirectives}.
|
|
264
|
+
*/
|
|
265
|
+
csp?: OxyCspExtensions;
|
|
266
|
+
/**
|
|
267
|
+
* Everything Helmet does that is not the CSP: `hsts`, `frameguard`,
|
|
268
|
+
* `referrerPolicy`, `crossOriginResourcePolicy`, … Passed straight through.
|
|
269
|
+
*
|
|
270
|
+
* `contentSecurityPolicy` is typed `never` on purpose: the CSP is owned by
|
|
271
|
+
* this helper so the baseline cannot be replaced (nor the `'self'` guarantee
|
|
272
|
+
* bypassed) by an app that hands Helmet its own directive block. Extend it
|
|
273
|
+
* through `csp` instead.
|
|
274
|
+
*/
|
|
275
|
+
helmet?: HelmetOptions & { contentSecurityPolicy?: never };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build the shared Oxy security-headers middleware: Helmet with the Oxy CSP
|
|
280
|
+
* baseline plus this app's extensions.
|
|
281
|
+
*
|
|
282
|
+
* @example
|
|
283
|
+
* ```ts
|
|
284
|
+
* app.use(createOxySecurityHeaders({
|
|
285
|
+
* csp: {
|
|
286
|
+
* connectSrc: ['https://api.example.com', 'wss://api.example.com'],
|
|
287
|
+
* frameSrc: ['https://player.vimeo.com'],
|
|
288
|
+
* },
|
|
289
|
+
* helmet: { crossOriginResourcePolicy: { policy: 'cross-origin' } },
|
|
290
|
+
* }));
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
export function createOxySecurityHeaders(options: OxySecurityHeadersOptions = {}): RequestHandler {
|
|
294
|
+
const { csp, helmet: helmetOptions } = options;
|
|
295
|
+
const directives = buildOxyCspDirectives(csp);
|
|
296
|
+
|
|
297
|
+
return helmet({
|
|
298
|
+
...helmetOptions,
|
|
299
|
+
// `useDefaults: false`: the baseline above is the whole policy, so what the
|
|
300
|
+
// browser receives is exactly what `buildOxyCspDirectives` returns — no
|
|
301
|
+
// silent union with Helmet's defaults that tests would never see.
|
|
302
|
+
contentSecurityPolicy: { useDefaults: false, directives },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -433,11 +433,9 @@ describe('AccountDialogController — switchTo (uniform switch)', () => {
|
|
|
433
433
|
expect(commitSession.mock.calls[0][0]).toMatchObject({ sessionId: 'sess-org', accessToken: 'access-org' });
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
-
it('commits a graph switch via the IN-PLACE commitSwitchedSession — never
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
// commitSwitchedSession (in-place) and NEVER commitSession (which may
|
|
440
|
-
// redirect on an official web origin).
|
|
436
|
+
it('commits a graph switch via the IN-PLACE commitSwitchedSession — never commitSession', async () => {
|
|
437
|
+
// An account switch must use commitSwitchedSession (in-place) and NEVER
|
|
438
|
+
// commitSession when both funnels are wired.
|
|
441
439
|
const oxy = makeOxy();
|
|
442
440
|
const sc = new TestSessionClient(host());
|
|
443
441
|
sc.set(state([{ accountId: 'a1', sessionId: 's1' }], 'a1'));
|
|
@@ -302,21 +302,17 @@ export interface AccountDialogControllerOptions {
|
|
|
302
302
|
* `SessionClient.registerAndActivate` (registration + activation only — no
|
|
303
303
|
* provider-side durable persist/hydration).
|
|
304
304
|
*
|
|
305
|
-
* This is the SIGN-IN commit:
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* origin legitimately needs that. An account SWITCH does NOT — see
|
|
309
|
-
* {@link commitSwitchedSession}.
|
|
305
|
+
* This is the SIGN-IN commit: registers the session into the host's device
|
|
306
|
+
* set with durable persist + profile hydration. An account SWITCH uses
|
|
307
|
+
* {@link commitSwitchedSession} instead — see below.
|
|
310
308
|
*/
|
|
311
309
|
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
312
310
|
/**
|
|
313
311
|
* Commit a minted graph SWITCH session into the host's session set — same
|
|
314
312
|
* device-first registration + durable persist + profile hydration as
|
|
315
|
-
* {@link commitSession}, but IN-PLACE: it must NOT
|
|
316
|
-
*
|
|
317
|
-
* device
|
|
318
|
-
* re-syncing is redundant and a full-page redirect on switch is the exact
|
|
319
|
-
* regression this separation prevents. Cross-tab/app propagation of the switch
|
|
313
|
+
* {@link commitSession}, but IN-PLACE: it must NOT re-run sign-in side effects
|
|
314
|
+
* that belong only to a fresh authorization (for example, a redundant full
|
|
315
|
+
* device-set reconcile on switch). Cross-tab/app propagation of the switch
|
|
320
316
|
* still happens instantly via the server's device-scoped `session_state` /
|
|
321
317
|
* `session_accounts_changed` socket broadcast — no navigation required.
|
|
322
318
|
*
|
|
@@ -811,9 +807,9 @@ export class AccountDialogController {
|
|
|
811
807
|
accessToken: result.accessToken,
|
|
812
808
|
},
|
|
813
809
|
result.user,
|
|
814
|
-
// A switch is IN-PLACE:
|
|
815
|
-
//
|
|
816
|
-
//
|
|
810
|
+
// A switch is IN-PLACE: use the switch commit funnel (not sign-in).
|
|
811
|
+
// Cross-tab/app propagation rides the server's `session_state` socket
|
|
812
|
+
// broadcast, not a navigation.
|
|
817
813
|
{ fromSwitch: true },
|
|
818
814
|
);
|
|
819
815
|
}
|
|
@@ -1349,11 +1345,9 @@ export class AccountDialogController {
|
|
|
1349
1345
|
* consumer's commit funnel (durable persist + hydration); falls back to
|
|
1350
1346
|
* `SessionClient.registerAndActivate` (registration + activation only).
|
|
1351
1347
|
*
|
|
1352
|
-
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
* switch funnel is not wired it falls back to the sign-in funnel, then to
|
|
1356
|
-
* `registerAndActivate`.
|
|
1348
|
+
* A SWITCH (`opts.fromSwitch`) uses the IN-PLACE `commitSwitchedSession` funnel;
|
|
1349
|
+
* a SIGN-IN uses `commitSession`. When the switch funnel is not wired it falls
|
|
1350
|
+
* back to the sign-in funnel, then to `registerAndActivate`.
|
|
1357
1351
|
*/
|
|
1358
1352
|
private async commitAuthorizedSession(
|
|
1359
1353
|
session: SessionLoginResponse,
|