@oxyhq/core 21.0.2 → 21.1.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.
@@ -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 csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
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}`,