@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.
@@ -81,7 +81,26 @@ export function OxyServicesUtilityMixin(Base) {
81
81
  return cached.result;
82
82
  }
83
83
  try {
84
- const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000 });
84
+ // The verify endpoint is service-to-service and admits only a
85
+ // platform-TRUSTED calling application, so this call must carry the
86
+ // VERIFIER's own service token. Sent explicitly rather than through
87
+ // `makeServiceRequest`, which would drop `retry: false` and the timeout
88
+ // — and those two are not incidental: this runs inside request-handling
89
+ // middleware, so an inner retry loop multiplies the latency of every
90
+ // delegated request by the number of attempts.
91
+ //
92
+ // A verifier with no service credentials configured throws here and
93
+ // lands in the catch below, which is the correct outcome. A host that
94
+ // cannot prove who it is has no business being told which users have
95
+ // delegated to which applications, and the 60s negative cache stops a
96
+ // misconfigured deployment from turning every request into a round trip.
97
+ const serviceToken = await this.getServiceToken();
98
+ const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, {
99
+ cache: false,
100
+ retry: false,
101
+ timeout: 5000,
102
+ headers: { Authorization: `Bearer ${serviceToken}` },
103
+ });
85
104
  const authorized = Boolean(result && result.authorized);
86
105
  const verified = authorized
87
106
  ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
@@ -778,6 +797,20 @@ export function OxyServicesUtilityMixin(Base) {
778
797
  * service requests require the app scope. Delegated user requests require
779
798
  * BOTH the app scope and the per-user delegation scope.
780
799
  *
800
+ * The intersection is the point, not a redundancy, because the two scope
801
+ * lists answer different questions and neither implies the other:
802
+ *
803
+ * `serviceApp.scopes` what the PLATFORM allows this application to do
804
+ * (credential ∩ application ceiling, at mint time)
805
+ * `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
806
+ *
807
+ * Requiring only the app scope would let an application do to a user
808
+ * something that user never consented to; requiring only the grant would let
809
+ * a user hand an application authority staff never gave it, so a revoked
810
+ * platform scope would keep working for every user who had already
811
+ * consented. Effective authority is the intersection, and this is where it
812
+ * is taken.
813
+ *
781
814
  * Requests authenticated as a regular user (no service token) are rejected
782
815
  * with 403 — scope-protected endpoints are service-to-service by design.
783
816
  *
@@ -22,7 +22,13 @@ export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamErr
22
22
  export { createOxyCors } from './cors.js';
23
23
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
24
24
  // Oxy API/CDN origins) with additive, per-app extensions.
25
- export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, formatOxyCspPolicy, OXY_CSP_BASELINE, } from './securityHeaders.js';
25
+ //
26
+ // `extractInlineScripts` / `inlineScriptCspHash` / `cspSourcesFor` are exported
27
+ // so a post-deploy gate can ask the SERVED document and the SERVED policy the
28
+ // same questions `buildOxyPagesHeaders` asked the built ones. A gate that
29
+ // re-implemented the scan or the parse would be testing its own copy, and would
30
+ // agree with a broken original.
31
+ export { buildOxyCspDirectives, buildOxyPagesHeaders, createOxySecurityHeaders, cspSourcesFor, extractInlineScripts, formatOxyCspPolicy, inlineScriptCspHash, OXY_CSP_BASELINE, } from './securityHeaders.js';
26
32
  // Constant-time secret comparison.
27
33
  export { verifySecret } from './verifySecret.js';
28
34
  // Cross-service user-invalidation signal: oxy-api publishes when identity
@@ -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
@@ -53,6 +66,7 @@
53
66
  *
54
67
  * Node/Express-only: exported solely from `@oxyhq/core/server`.
55
68
  */
69
+ import { createHash } from 'node:crypto';
56
70
  import helmet from 'helmet';
57
71
  /** CSP keyword for "this origin". Always present in every open baseline directive. */
58
72
  const SELF = "'self'";
@@ -176,13 +190,139 @@ export function formatOxyCspPolicy(directives) {
176
190
  .map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
177
191
  .join('; ');
178
192
  }
193
+ /**
194
+ * The source list one directive carries in a serialized policy, or `[]` when
195
+ * the policy does not name that directive. The inverse of
196
+ * {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
197
+ * either caller: the post-deploy gate parses the policy the ORIGIN serves while
198
+ * the unit test parses the one the middleware renders, so a copy in each would
199
+ * let the header shape change with the test still green and the gate reading
200
+ * `[]` — reporting every script blocked, which reads as a broken app rather
201
+ * than as a broken parser.
202
+ *
203
+ * A directive present with no sources (`upgrade-insecure-requests`) and a
204
+ * directive absent entirely both answer `[]`. Callers that need to tell those
205
+ * apart are asking a different question than "what is allowed here".
206
+ */
207
+ export function cspSourcesFor(policy, directive) {
208
+ const segment = policy
209
+ .split(';')
210
+ .map((entry) => entry.trim())
211
+ .find((entry) => entry === directive || entry.startsWith(`${directive} `));
212
+ return segment === undefined ? [] : segment.split(/\s+/).slice(1);
213
+ }
214
+ /**
215
+ * Index of the `>` that closes a tag whose attribute region starts at `from`,
216
+ * or `-1` if the document ends first. Quote-aware: a `>` inside an attribute
217
+ * VALUE does not close the tag.
218
+ *
219
+ * No HTML any Oxy build currently emits contains such an attribute, so this is
220
+ * not load-bearing today — it is here because the same scanner reads the SERVED
221
+ * document in the post-deploy gate, and what an edge injects into that document
222
+ * is not ours to constrain. Getting it wrong is not a parse error: the body
223
+ * window shifts, the hash is computed over the wrong bytes, and the script is
224
+ * blocked exactly as if no hash had been derived at all.
225
+ */
226
+ function findTagEnd(html, from) {
227
+ let quote = null;
228
+ for (let index = from; index < html.length; index += 1) {
229
+ const character = html[index];
230
+ if (quote !== null) {
231
+ if (character === quote)
232
+ quote = null;
233
+ continue;
234
+ }
235
+ if (character === '"' || character === "'") {
236
+ quote = character;
237
+ continue;
238
+ }
239
+ if (character === '>')
240
+ return index;
241
+ }
242
+ return -1;
243
+ }
244
+ /**
245
+ * Every inline `<script>` body in an HTML document, in document order. A
246
+ * `<script src=…>` is a URL the source list already governs and is skipped.
247
+ *
248
+ * Scanned rather than matched with one regex because the two failure modes are
249
+ * not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
250
+ * MISSED body silently reinstates the exact breakage this exists to prevent.
251
+ * So the scan errs toward finding them — it walks the open tag quote-aware
252
+ * instead of letting a `>` inside an attribute value truncate it.
253
+ *
254
+ * The type attribute is deliberately not consulted. Whether a given `type`
255
+ * executes is a browser decision (and it changes: `importmap` and
256
+ * `speculationrules` were both once inert), and pinning the exact bytes of a
257
+ * data block we ship ourselves weakens nothing.
258
+ */
259
+ export function extractInlineScripts(html) {
260
+ const lowered = html.toLowerCase();
261
+ const bodies = [];
262
+ const openTag = /<script\b/gi;
263
+ let match = openTag.exec(html);
264
+ while (match !== null) {
265
+ const attributesStart = match.index + match[0].length;
266
+ const attributesEnd = findTagEnd(html, attributesStart);
267
+ if (attributesEnd < 0)
268
+ break;
269
+ const bodyStart = attributesEnd + 1;
270
+ const bodyEnd = lowered.indexOf('</script', bodyStart);
271
+ if (bodyEnd < 0)
272
+ break;
273
+ if (!/\bsrc\s*=/i.test(html.slice(attributesStart, attributesEnd))) {
274
+ bodies.push(html.slice(bodyStart, bodyEnd));
275
+ }
276
+ openTag.lastIndex = bodyEnd;
277
+ match = openTag.exec(html);
278
+ }
279
+ return bodies;
280
+ }
281
+ /**
282
+ * The `'sha256-…'` source that allows one inline script, hashed over its exact
283
+ * bytes as CSP specifies — no trimming, no normalization. One byte of
284
+ * whitespace either way is a different hash and the script stays blocked.
285
+ */
286
+ export function inlineScriptCspHash(source) {
287
+ return `'sha256-${createHash('sha256').update(source, 'utf8').digest('base64')}'`;
288
+ }
289
+ /**
290
+ * Ceiling on how many derived inline-script hashes may enter one `_headers`
291
+ * block. Nothing in an Oxy app authors an inline script, so the realistic
292
+ * count is the ONE Expo Router hydration flag — deduped across every route's
293
+ * HTML, because it is byte-identical in all of them.
294
+ *
295
+ * The ceiling exists because one future change breaks that: a route loader
296
+ * makes Expo emit a SECOND inline script, `__EXPO_ROUTER_LOADER_DATA__`, whose
297
+ * bytes differ per route. The hashes stay CORRECT (they are derived from the
298
+ * same build that ships), but the count becomes the route count and the policy
299
+ * grows without bound on every response. That is a decision to take
300
+ * deliberately, so it arrives as a red build rather than a quietly enormous
301
+ * header.
302
+ */
303
+ const MAX_INLINE_SCRIPT_HASHES = 8;
179
304
  /**
180
305
  * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
181
306
  * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
182
307
  * hardening headers Helmet would add on an Express HTML backend.
308
+ *
309
+ * Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
310
+ * an additional source, so `'self'` and the beacon host keep matching external
311
+ * scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
312
+ * is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
313
+ * react-native-web's runtime stylesheet, and a style hash would silently switch
314
+ * that off and render every Oxy web app unstyled.)
183
315
  */
184
316
  export function buildOxyPagesHeaders(options = {}) {
185
- const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
317
+ const hashes = [
318
+ ...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
319
+ ];
320
+ if (hashes.length > MAX_INLINE_SCRIPT_HASHES) {
321
+ throw new RangeError(`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.`);
322
+ }
323
+ const csp = formatOxyCspPolicy(buildOxyCspDirectives(hashes.length === 0
324
+ ? options.csp
325
+ : { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] }));
186
326
  const lines = [
187
327
  '/*',
188
328
  ` Content-Security-Policy: ${csp}`,