@oxyhq/core 21.0.2 → 21.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.utility.js +34 -1
- package/dist/cjs/server/index.js +10 -1
- package/dist/cjs/server/securityHeaders.js +144 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.utility.js +34 -1
- package/dist/esm/server/index.js +7 -1
- package/dist/esm/server/securityHeaders.js +141 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.accounts.d.ts +40 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +24 -2
- package/dist/types/server/index.d.ts +1 -1
- package/dist/types/server/securityHeaders.d.ts +67 -0
- package/package.json +3 -3
- package/src/mixins/OxyServices.accounts.ts +40 -0
- package/src/mixins/OxyServices.utility.ts +46 -3
- package/src/mixins/__tests__/accounts.test.ts +11 -4
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/server/__tests__/securityHeaders.test.ts +116 -12
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +164 -1
|
@@ -3,7 +3,10 @@ import {
|
|
|
3
3
|
buildOxyCspDirectives,
|
|
4
4
|
buildOxyPagesHeaders,
|
|
5
5
|
createOxySecurityHeaders,
|
|
6
|
+
cspSourcesFor,
|
|
7
|
+
extractInlineScripts,
|
|
6
8
|
formatOxyCspPolicy,
|
|
9
|
+
inlineScriptCspHash,
|
|
7
10
|
OXY_CSP_BASELINE,
|
|
8
11
|
type OxyCspExtensions,
|
|
9
12
|
} from '../securityHeaders';
|
|
@@ -27,14 +30,11 @@ function renderPolicy(options: Parameters<typeof createOxySecurityHeaders>[0]):
|
|
|
27
30
|
return headers['Content-Security-Policy'] ?? '';
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
/** The
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.find((segment) => segment === directive || segment.startsWith(`${directive} `));
|
|
36
|
-
if (found === undefined) return [];
|
|
37
|
-
return found.split(/\s+/).slice(1);
|
|
33
|
+
/** The CSP value parsed back out of a Cloudflare Pages `_headers` block. */
|
|
34
|
+
function cspOf(block: string): string {
|
|
35
|
+
const line = block.split('\n').find((entry) => entry.trim().startsWith('Content-Security-Policy:'));
|
|
36
|
+
if (line === undefined) throw new Error('no Content-Security-Policy line in _headers block');
|
|
37
|
+
return line.trim().slice('Content-Security-Policy:'.length).trim();
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
describe('@oxyhq/core/server buildOxyCspDirectives', () => {
|
|
@@ -179,12 +179,116 @@ describe('@oxyhq/core/server buildOxyPagesHeaders', () => {
|
|
|
179
179
|
});
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* The inline script Expo Router's static export emits, verbatim, and the hash
|
|
184
|
+
* `accounts.oxy.so` was measured rejecting on 2026-08-21. Pinned as a LITERAL
|
|
185
|
+
* rather than recomputed: a test that derives the expected value the same way
|
|
186
|
+
* the code does would pass against any hashing bug they share.
|
|
187
|
+
*/
|
|
188
|
+
const EXPO_HYDRATE_SCRIPT = 'globalThis.__EXPO_ROUTER_HYDRATE__=true;';
|
|
189
|
+
const EXPO_HYDRATE_SHA256 = "'sha256-67fhrP0+BkBqmgGGXTtgiVO/9EQs3QruYNU/7fnRkI8='";
|
|
190
|
+
|
|
191
|
+
/** A static Expo export's HTML, reduced to the parts that decide the policy. */
|
|
192
|
+
function expoExportHtml(body = EXPO_HYDRATE_SCRIPT): string {
|
|
193
|
+
return [
|
|
194
|
+
'<!DOCTYPE html><html><head>',
|
|
195
|
+
'<script src="/_expo/static/js/web/entry-abc.js" defer></script>',
|
|
196
|
+
`<script type="module">${body}</script>`,
|
|
197
|
+
'</head><body><div id="root"></div></body></html>',
|
|
198
|
+
].join('');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
describe('@oxyhq/core/server extractInlineScripts', () => {
|
|
202
|
+
it('returns inline bodies and skips external scripts', () => {
|
|
203
|
+
expect(extractInlineScripts(expoExportHtml())).toEqual([EXPO_HYDRATE_SCRIPT]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('does not let a ">" inside an attribute value truncate an inline tag', () => {
|
|
207
|
+
// Truncating here does not throw: the body window shifts right and the hash
|
|
208
|
+
// is taken over `b'>globalThis…`, which allows nothing. Note the attribute
|
|
209
|
+
// must precede the body for this to discriminate — an EXTERNAL script whose
|
|
210
|
+
// `src` comes first is skipped either way, which is why the beacon shape is
|
|
211
|
+
// not the case under test here.
|
|
212
|
+
const html = `<script type="module" data-x='a>b'>${EXPO_HYDRATE_SCRIPT}</script>`;
|
|
213
|
+
expect(extractInlineScripts(html)).toEqual([EXPO_HYDRATE_SCRIPT]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('still recognizes a src that follows a ">"-bearing attribute', () => {
|
|
217
|
+
// The mirror failure: truncation hides `src` from the attribute slice, so
|
|
218
|
+
// an external script is mistaken for an inline one and contributes a hash
|
|
219
|
+
// over a fragment of its own tag.
|
|
220
|
+
expect(extractInlineScripts(`<script data-x='a>b' src="/entry.js"></script>`)).toEqual([]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('hashes the exact bytes, so whitespace changes the source', () => {
|
|
224
|
+
expect(inlineScriptCspHash(EXPO_HYDRATE_SCRIPT)).toBe(EXPO_HYDRATE_SHA256);
|
|
225
|
+
expect(inlineScriptCspHash(` ${EXPO_HYDRATE_SCRIPT}`)).not.toBe(EXPO_HYDRATE_SHA256);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('@oxyhq/core/server buildOxyPagesHeaders inline-script hashes', () => {
|
|
230
|
+
it('allows the built HTML\'s inline script by hash', () => {
|
|
231
|
+
const block = buildOxyPagesHeaders({ html: [expoExportHtml()] });
|
|
232
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toEqual([
|
|
233
|
+
"'self'",
|
|
234
|
+
CLOUDFLARE_SCRIPT_HOST,
|
|
235
|
+
EXPO_HYDRATE_SHA256,
|
|
236
|
+
]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('emits no hash at all when the build has no inline script', () => {
|
|
240
|
+
// Negative control for the assertion above: without it, "contains a
|
|
241
|
+
// sha256-" would be satisfied by a builder that hashed unconditionally.
|
|
242
|
+
const block = buildOxyPagesHeaders({ html: ['<html><body>nothing inline</body></html>'] });
|
|
243
|
+
expect(cspOf(block)).not.toContain('sha256-');
|
|
244
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('dedupes one route-per-file export down to a single hash', () => {
|
|
248
|
+
const block = buildOxyPagesHeaders({
|
|
249
|
+
html: [expoExportHtml(), expoExportHtml(), expoExportHtml()],
|
|
250
|
+
});
|
|
251
|
+
expect(cspOf(block).match(/sha256-/g)).toHaveLength(1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('keeps per-app extensions and the baseline alongside the hash', () => {
|
|
255
|
+
const block = buildOxyPagesHeaders({
|
|
256
|
+
csp: { imgSrc: ['blob:'], connectSrc: ['blob:'] },
|
|
257
|
+
html: [expoExportHtml()],
|
|
258
|
+
});
|
|
259
|
+
expect(cspSourcesFor(cspOf(block), 'img-src')).toContain('blob:');
|
|
260
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toContain(EXPO_HYDRATE_SHA256);
|
|
261
|
+
expect(cspSourcesFor(cspOf(block), 'script-src')).toContain("'self'");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('never hashes styles, which would disable style-src unsafe-inline', () => {
|
|
265
|
+
// A style hash would neutralize 'unsafe-inline' and render every
|
|
266
|
+
// react-native-web app unstyled — the one directive that must stay open.
|
|
267
|
+
const block = buildOxyPagesHeaders({
|
|
268
|
+
html: ['<html><head><style>.a{color:red}</style></head></html>'],
|
|
269
|
+
});
|
|
270
|
+
expect(cspSourcesFor(cspOf(block), 'style-src')).toEqual(["'self'", "'unsafe-inline'"]);
|
|
271
|
+
expect(cspOf(block)).not.toContain('sha256-');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('refuses a build whose inline scripts vary per route', () => {
|
|
275
|
+
// An Expo route loader emits `__EXPO_ROUTER_LOADER_DATA__` with different
|
|
276
|
+
// bytes per route. The hashes would still be correct; the policy would grow
|
|
277
|
+
// with the route count. That has to be a decision, so it fails loudly.
|
|
278
|
+
const perRoute = Array.from({ length: 9 }, (_, index) =>
|
|
279
|
+
expoExportHtml(`globalThis.__EXPO_ROUTER_LOADER_DATA__={"r":${index}};`),
|
|
280
|
+
);
|
|
281
|
+
expect(() => buildOxyPagesHeaders({ html: perRoute })).toThrow(RangeError);
|
|
282
|
+
expect(() => buildOxyPagesHeaders({ html: perRoute.slice(0, 8) })).not.toThrow();
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
182
286
|
describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
183
287
|
it('sends the resolved baseline as a real Content-Security-Policy header', () => {
|
|
184
288
|
const policy = renderPolicy({});
|
|
185
289
|
|
|
186
|
-
expect(
|
|
187
|
-
expect(
|
|
290
|
+
expect(cspSourcesFor(policy, 'script-src')).toEqual(["'self'", CLOUDFLARE_SCRIPT_HOST]);
|
|
291
|
+
expect(cspSourcesFor(policy, 'connect-src')).toEqual([
|
|
188
292
|
"'self'",
|
|
189
293
|
CLOUDFLARE_REPORT_HOST,
|
|
190
294
|
'https://api.oxy.so',
|
|
@@ -202,7 +306,7 @@ describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
|
202
306
|
},
|
|
203
307
|
});
|
|
204
308
|
|
|
205
|
-
expect(
|
|
309
|
+
expect(cspSourcesFor(policy, 'connect-src')).toEqual([
|
|
206
310
|
"'self'",
|
|
207
311
|
CLOUDFLARE_REPORT_HOST,
|
|
208
312
|
'https://api.oxy.so',
|
|
@@ -211,7 +315,7 @@ describe('@oxyhq/core/server createOxySecurityHeaders', () => {
|
|
|
211
315
|
'https://api.mention.earth',
|
|
212
316
|
'wss://api.mention.earth',
|
|
213
317
|
]);
|
|
214
|
-
expect(
|
|
318
|
+
expect(cspSourcesFor(policy, 'frame-src')).toEqual([
|
|
215
319
|
"'self'",
|
|
216
320
|
'https://www.youtube-nocookie.com',
|
|
217
321
|
]);
|
package/src/server/index.ts
CHANGED
|
@@ -71,11 +71,20 @@ export type { OxyCorsOptions } from './cors';
|
|
|
71
71
|
|
|
72
72
|
// Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
|
|
73
73
|
// Oxy API/CDN origins) with additive, per-app extensions.
|
|
74
|
+
//
|
|
75
|
+
// `extractInlineScripts` / `inlineScriptCspHash` / `cspSourcesFor` are exported
|
|
76
|
+
// so a post-deploy gate can ask the SERVED document and the SERVED policy the
|
|
77
|
+
// same questions `buildOxyPagesHeaders` asked the built ones. A gate that
|
|
78
|
+
// re-implemented the scan or the parse would be testing its own copy, and would
|
|
79
|
+
// agree with a broken original.
|
|
74
80
|
export {
|
|
75
81
|
buildOxyCspDirectives,
|
|
76
82
|
buildOxyPagesHeaders,
|
|
77
83
|
createOxySecurityHeaders,
|
|
84
|
+
cspSourcesFor,
|
|
85
|
+
extractInlineScripts,
|
|
78
86
|
formatOxyCspPolicy,
|
|
87
|
+
inlineScriptCspHash,
|
|
79
88
|
OXY_CSP_BASELINE,
|
|
80
89
|
} from './securityHeaders';
|
|
81
90
|
export type {
|
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* cannot pass their own `contentSecurityPolicy` through to Helmet at all
|
|
34
34
|
* (the option is typed `never`).
|
|
35
35
|
*
|
|
36
|
+
* 3. A STATIC EXPO EXPORT SHIPS AN INLINE SCRIPT THE BASELINE FORBIDS.
|
|
37
|
+
* `web.output: 'static'` makes Expo Router emit
|
|
38
|
+
* `<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>`,
|
|
39
|
+
* which is what tells the client entry to call `hydrateRoot` instead of
|
|
40
|
+
* `createRoot().render()`. Nothing in app code puts it there, so — like the
|
|
41
|
+
* Cloudflare beacon above — an app cannot allowlist it from the app side.
|
|
42
|
+
* Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw
|
|
43
|
+
* away the server-rendered markup and re-rendered from scratch, with only a
|
|
44
|
+
* console error to show for it. The hashes are therefore DERIVED from the
|
|
45
|
+
* built output rather than hand-written (see {@link extractInlineScripts}):
|
|
46
|
+
* a hash pasted into config is correct exactly until the build changes one
|
|
47
|
+
* byte, and then it fails the same silent way.
|
|
48
|
+
*
|
|
36
49
|
* WHAT IT PROVIDES
|
|
37
50
|
* ----------------
|
|
38
51
|
* `createOxySecurityHeaders(options)` returns the Helmet middleware with the
|
|
@@ -54,6 +67,7 @@
|
|
|
54
67
|
* Node/Express-only: exported solely from `@oxyhq/core/server`.
|
|
55
68
|
*/
|
|
56
69
|
|
|
70
|
+
import { createHash } from 'node:crypto';
|
|
57
71
|
import type { RequestHandler } from 'express';
|
|
58
72
|
import helmet, { type HelmetOptions } from 'helmet';
|
|
59
73
|
|
|
@@ -226,6 +240,123 @@ export function formatOxyCspPolicy(directives: Record<string, string[]>): string
|
|
|
226
240
|
.join('; ');
|
|
227
241
|
}
|
|
228
242
|
|
|
243
|
+
/**
|
|
244
|
+
* The source list one directive carries in a serialized policy, or `[]` when
|
|
245
|
+
* the policy does not name that directive. The inverse of
|
|
246
|
+
* {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
|
|
247
|
+
* either caller: the post-deploy gate parses the policy the ORIGIN serves while
|
|
248
|
+
* the unit test parses the one the middleware renders, so a copy in each would
|
|
249
|
+
* let the header shape change with the test still green and the gate reading
|
|
250
|
+
* `[]` — reporting every script blocked, which reads as a broken app rather
|
|
251
|
+
* than as a broken parser.
|
|
252
|
+
*
|
|
253
|
+
* A directive present with no sources (`upgrade-insecure-requests`) and a
|
|
254
|
+
* directive absent entirely both answer `[]`. Callers that need to tell those
|
|
255
|
+
* apart are asking a different question than "what is allowed here".
|
|
256
|
+
*/
|
|
257
|
+
export function cspSourcesFor(policy: string, directive: string): string[] {
|
|
258
|
+
const segment = policy
|
|
259
|
+
.split(';')
|
|
260
|
+
.map((entry) => entry.trim())
|
|
261
|
+
.find((entry) => entry === directive || entry.startsWith(`${directive} `));
|
|
262
|
+
return segment === undefined ? [] : segment.split(/\s+/).slice(1);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Index of the `>` that closes a tag whose attribute region starts at `from`,
|
|
267
|
+
* or `-1` if the document ends first. Quote-aware: a `>` inside an attribute
|
|
268
|
+
* VALUE does not close the tag.
|
|
269
|
+
*
|
|
270
|
+
* No HTML any Oxy build currently emits contains such an attribute, so this is
|
|
271
|
+
* not load-bearing today — it is here because the same scanner reads the SERVED
|
|
272
|
+
* document in the post-deploy gate, and what an edge injects into that document
|
|
273
|
+
* is not ours to constrain. Getting it wrong is not a parse error: the body
|
|
274
|
+
* window shifts, the hash is computed over the wrong bytes, and the script is
|
|
275
|
+
* blocked exactly as if no hash had been derived at all.
|
|
276
|
+
*/
|
|
277
|
+
function findTagEnd(html: string, from: number): number {
|
|
278
|
+
let quote: '"' | "'" | null = null;
|
|
279
|
+
for (let index = from; index < html.length; index += 1) {
|
|
280
|
+
const character = html[index];
|
|
281
|
+
if (quote !== null) {
|
|
282
|
+
if (character === quote) quote = null;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (character === '"' || character === "'") {
|
|
286
|
+
quote = character;
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (character === '>') return index;
|
|
290
|
+
}
|
|
291
|
+
return -1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Every inline `<script>` body in an HTML document, in document order. A
|
|
296
|
+
* `<script src=…>` is a URL the source list already governs and is skipped.
|
|
297
|
+
*
|
|
298
|
+
* Scanned rather than matched with one regex because the two failure modes are
|
|
299
|
+
* not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
|
|
300
|
+
* MISSED body silently reinstates the exact breakage this exists to prevent.
|
|
301
|
+
* So the scan errs toward finding them — it walks the open tag quote-aware
|
|
302
|
+
* instead of letting a `>` inside an attribute value truncate it.
|
|
303
|
+
*
|
|
304
|
+
* The type attribute is deliberately not consulted. Whether a given `type`
|
|
305
|
+
* executes is a browser decision (and it changes: `importmap` and
|
|
306
|
+
* `speculationrules` were both once inert), and pinning the exact bytes of a
|
|
307
|
+
* data block we ship ourselves weakens nothing.
|
|
308
|
+
*/
|
|
309
|
+
export function extractInlineScripts(html: string): string[] {
|
|
310
|
+
const lowered = html.toLowerCase();
|
|
311
|
+
const bodies: string[] = [];
|
|
312
|
+
const openTag = /<script\b/gi;
|
|
313
|
+
|
|
314
|
+
let match = openTag.exec(html);
|
|
315
|
+
while (match !== null) {
|
|
316
|
+
const attributesStart = match.index + match[0].length;
|
|
317
|
+
const attributesEnd = findTagEnd(html, attributesStart);
|
|
318
|
+
if (attributesEnd < 0) break;
|
|
319
|
+
|
|
320
|
+
const bodyStart = attributesEnd + 1;
|
|
321
|
+
const bodyEnd = lowered.indexOf('</script', bodyStart);
|
|
322
|
+
if (bodyEnd < 0) break;
|
|
323
|
+
|
|
324
|
+
if (!/\bsrc\s*=/i.test(html.slice(attributesStart, attributesEnd))) {
|
|
325
|
+
bodies.push(html.slice(bodyStart, bodyEnd));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
openTag.lastIndex = bodyEnd;
|
|
329
|
+
match = openTag.exec(html);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return bodies;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The `'sha256-…'` source that allows one inline script, hashed over its exact
|
|
337
|
+
* bytes as CSP specifies — no trimming, no normalization. One byte of
|
|
338
|
+
* whitespace either way is a different hash and the script stays blocked.
|
|
339
|
+
*/
|
|
340
|
+
export function inlineScriptCspHash(source: string): string {
|
|
341
|
+
return `'sha256-${createHash('sha256').update(source, 'utf8').digest('base64')}'`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Ceiling on how many derived inline-script hashes may enter one `_headers`
|
|
346
|
+
* block. Nothing in an Oxy app authors an inline script, so the realistic
|
|
347
|
+
* count is the ONE Expo Router hydration flag — deduped across every route's
|
|
348
|
+
* HTML, because it is byte-identical in all of them.
|
|
349
|
+
*
|
|
350
|
+
* The ceiling exists because one future change breaks that: a route loader
|
|
351
|
+
* makes Expo emit a SECOND inline script, `__EXPO_ROUTER_LOADER_DATA__`, whose
|
|
352
|
+
* bytes differ per route. The hashes stay CORRECT (they are derived from the
|
|
353
|
+
* same build that ships), but the count becomes the route count and the policy
|
|
354
|
+
* grows without bound on every response. That is a decision to take
|
|
355
|
+
* deliberately, so it arrives as a red build rather than a quietly enormous
|
|
356
|
+
* header.
|
|
357
|
+
*/
|
|
358
|
+
const MAX_INLINE_SCRIPT_HASHES = 8;
|
|
359
|
+
|
|
229
360
|
export interface OxyPagesHeadersOptions {
|
|
230
361
|
/** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
|
|
231
362
|
csp?: OxyCspExtensions;
|
|
@@ -234,15 +365,47 @@ export interface OxyPagesHeadersOptions {
|
|
|
234
365
|
* HTTPS only, so static deploys should keep this on.
|
|
235
366
|
*/
|
|
236
367
|
hsts?: boolean;
|
|
368
|
+
/**
|
|
369
|
+
* The BUILT HTML documents this `_headers` will be served alongside. Every
|
|
370
|
+
* inline script found in them is allowed by hash, added to `script-src`.
|
|
371
|
+
*
|
|
372
|
+
* Passing the built output — rather than hand-writing a hash into
|
|
373
|
+
* `oxy.pages-headers.json` — is the whole point: a pasted hash is correct
|
|
374
|
+
* until the generator changes one byte of that script, and then the script is
|
|
375
|
+
* blocked again with nothing but a console error to show for it.
|
|
376
|
+
*/
|
|
377
|
+
html?: readonly string[];
|
|
237
378
|
}
|
|
238
379
|
|
|
239
380
|
/**
|
|
240
381
|
* Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
|
|
241
382
|
* same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
|
|
242
383
|
* hardening headers Helmet would add on an Express HTML backend.
|
|
384
|
+
*
|
|
385
|
+
* Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
|
|
386
|
+
* an additional source, so `'self'` and the beacon host keep matching external
|
|
387
|
+
* scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
|
|
388
|
+
* is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
|
|
389
|
+
* react-native-web's runtime stylesheet, and a style hash would silently switch
|
|
390
|
+
* that off and render every Oxy web app unstyled.)
|
|
243
391
|
*/
|
|
244
392
|
export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): string {
|
|
245
|
-
const
|
|
393
|
+
const hashes = [
|
|
394
|
+
...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
|
|
395
|
+
];
|
|
396
|
+
if (hashes.length > MAX_INLINE_SCRIPT_HASHES) {
|
|
397
|
+
throw new RangeError(
|
|
398
|
+
`Oxy CSP: ${hashes.length} distinct inline scripts in the built HTML exceeds the ${MAX_INLINE_SCRIPT_HASHES}-hash ceiling. A per-route inline data block (e.g. an Expo Router loader) is the likely cause; allow it deliberately rather than by raising this.`,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const csp = formatOxyCspPolicy(
|
|
403
|
+
buildOxyCspDirectives(
|
|
404
|
+
hashes.length === 0
|
|
405
|
+
? options.csp
|
|
406
|
+
: { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] },
|
|
407
|
+
),
|
|
408
|
+
);
|
|
246
409
|
const lines = [
|
|
247
410
|
'/*',
|
|
248
411
|
` Content-Security-Policy: ${csp}`,
|