@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.
@@ -84,7 +84,26 @@ function OxyServicesUtilityMixin(Base) {
84
84
  return cached.result;
85
85
  }
86
86
  try {
87
- const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000 });
87
+ // The verify endpoint is service-to-service and admits only a
88
+ // platform-TRUSTED calling application, so this call must carry the
89
+ // VERIFIER's own service token. Sent explicitly rather than through
90
+ // `makeServiceRequest`, which would drop `retry: false` and the timeout
91
+ // — and those two are not incidental: this runs inside request-handling
92
+ // middleware, so an inner retry loop multiplies the latency of every
93
+ // delegated request by the number of attempts.
94
+ //
95
+ // A verifier with no service credentials configured throws here and
96
+ // lands in the catch below, which is the correct outcome. A host that
97
+ // cannot prove who it is has no business being told which users have
98
+ // delegated to which applications, and the 60s negative cache stops a
99
+ // misconfigured deployment from turning every request into a round trip.
100
+ const serviceToken = await this.getServiceToken();
101
+ const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, {
102
+ cache: false,
103
+ retry: false,
104
+ timeout: 5000,
105
+ headers: { Authorization: `Bearer ${serviceToken}` },
106
+ });
88
107
  const authorized = Boolean(result && result.authorized);
89
108
  const verified = authorized
90
109
  ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
@@ -781,6 +800,20 @@ function OxyServicesUtilityMixin(Base) {
781
800
  * service requests require the app scope. Delegated user requests require
782
801
  * BOTH the app scope and the per-user delegation scope.
783
802
  *
803
+ * The intersection is the point, not a redundancy, because the two scope
804
+ * lists answer different questions and neither implies the other:
805
+ *
806
+ * `serviceApp.scopes` what the PLATFORM allows this application to do
807
+ * (credential ∩ application ceiling, at mint time)
808
+ * `serviceActingAs.scopes` what THIS USER allowed it to do (`app_grants`)
809
+ *
810
+ * Requiring only the app scope would let an application do to a user
811
+ * something that user never consented to; requiring only the grant would let
812
+ * a user hand an application authority staff never gave it, so a revoked
813
+ * platform scope would keep working for every user who had already
814
+ * consented. Effective authority is the intersection, and this is where it
815
+ * is taken.
816
+ *
784
817
  * Requests authenticated as a regular user (no service token) are rejected
785
818
  * with 403 — scope-protected endpoints are service-to-service by design.
786
819
  *
@@ -16,7 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.formatOxyCspPolicy = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
19
+ exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
20
20
  var auth_1 = require("./auth");
21
21
  Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
22
22
  Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
@@ -50,11 +50,20 @@ var cors_1 = require("./cors");
50
50
  Object.defineProperty(exports, "createOxyCors", { enumerable: true, get: function () { return cors_1.createOxyCors; } });
51
51
  // Shared Helmet + Content-Security-Policy baseline (Cloudflare Insights beacon,
52
52
  // Oxy API/CDN origins) with additive, per-app extensions.
53
+ //
54
+ // `extractInlineScripts` / `inlineScriptCspHash` / `cspSourcesFor` are exported
55
+ // so a post-deploy gate can ask the SERVED document and the SERVED policy the
56
+ // same questions `buildOxyPagesHeaders` asked the built ones. A gate that
57
+ // re-implemented the scan or the parse would be testing its own copy, and would
58
+ // agree with a broken original.
53
59
  var securityHeaders_1 = require("./securityHeaders");
54
60
  Object.defineProperty(exports, "buildOxyCspDirectives", { enumerable: true, get: function () { return securityHeaders_1.buildOxyCspDirectives; } });
55
61
  Object.defineProperty(exports, "buildOxyPagesHeaders", { enumerable: true, get: function () { return securityHeaders_1.buildOxyPagesHeaders; } });
56
62
  Object.defineProperty(exports, "createOxySecurityHeaders", { enumerable: true, get: function () { return securityHeaders_1.createOxySecurityHeaders; } });
63
+ Object.defineProperty(exports, "cspSourcesFor", { enumerable: true, get: function () { return securityHeaders_1.cspSourcesFor; } });
64
+ Object.defineProperty(exports, "extractInlineScripts", { enumerable: true, get: function () { return securityHeaders_1.extractInlineScripts; } });
57
65
  Object.defineProperty(exports, "formatOxyCspPolicy", { enumerable: true, get: function () { return securityHeaders_1.formatOxyCspPolicy; } });
66
+ Object.defineProperty(exports, "inlineScriptCspHash", { enumerable: true, get: function () { return securityHeaders_1.inlineScriptCspHash; } });
58
67
  Object.defineProperty(exports, "OXY_CSP_BASELINE", { enumerable: true, get: function () { return securityHeaders_1.OXY_CSP_BASELINE; } });
59
68
  // Constant-time secret comparison.
60
69
  var verifySecret_1 = require("./verifySecret");
@@ -34,6 +34,19 @@
34
34
  * cannot pass their own `contentSecurityPolicy` through to Helmet at all
35
35
  * (the option is typed `never`).
36
36
  *
37
+ * 3. A STATIC EXPO EXPORT SHIPS AN INLINE SCRIPT THE BASELINE FORBIDS.
38
+ * `web.output: 'static'` makes Expo Router emit
39
+ * `<script type="module">globalThis.__EXPO_ROUTER_HYDRATE__=true;</script>`,
40
+ * which is what tells the client entry to call `hydrateRoot` instead of
41
+ * `createRoot().render()`. Nothing in app code puts it there, so — like the
42
+ * Cloudflare beacon above — an app cannot allowlist it from the app side.
43
+ * Measured on `accounts.oxy.so` 2026-08-21: blocked, so every visit threw
44
+ * away the server-rendered markup and re-rendered from scratch, with only a
45
+ * console error to show for it. The hashes are therefore DERIVED from the
46
+ * built output rather than hand-written (see {@link extractInlineScripts}):
47
+ * a hash pasted into config is correct exactly until the build changes one
48
+ * byte, and then it fails the same silent way.
49
+ *
37
50
  * WHAT IT PROVIDES
38
51
  * ----------------
39
52
  * `createOxySecurityHeaders(options)` returns the Helmet middleware with the
@@ -61,8 +74,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
61
74
  exports.OXY_CSP_BASELINE = void 0;
62
75
  exports.buildOxyCspDirectives = buildOxyCspDirectives;
63
76
  exports.formatOxyCspPolicy = formatOxyCspPolicy;
77
+ exports.cspSourcesFor = cspSourcesFor;
78
+ exports.extractInlineScripts = extractInlineScripts;
79
+ exports.inlineScriptCspHash = inlineScriptCspHash;
64
80
  exports.buildOxyPagesHeaders = buildOxyPagesHeaders;
65
81
  exports.createOxySecurityHeaders = createOxySecurityHeaders;
82
+ const node_crypto_1 = require("node:crypto");
66
83
  const helmet_1 = __importDefault(require("helmet"));
67
84
  /** CSP keyword for "this origin". Always present in every open baseline directive. */
68
85
  const SELF = "'self'";
@@ -186,13 +203,139 @@ function formatOxyCspPolicy(directives) {
186
203
  .map(([name, sources]) => (sources.length === 0 ? name : `${name} ${sources.join(' ')}`))
187
204
  .join('; ');
188
205
  }
206
+ /**
207
+ * The source list one directive carries in a serialized policy, or `[]` when
208
+ * the policy does not name that directive. The inverse of
209
+ * {@link formatOxyCspPolicy}, and the reason it lives here rather than beside
210
+ * either caller: the post-deploy gate parses the policy the ORIGIN serves while
211
+ * the unit test parses the one the middleware renders, so a copy in each would
212
+ * let the header shape change with the test still green and the gate reading
213
+ * `[]` — reporting every script blocked, which reads as a broken app rather
214
+ * than as a broken parser.
215
+ *
216
+ * A directive present with no sources (`upgrade-insecure-requests`) and a
217
+ * directive absent entirely both answer `[]`. Callers that need to tell those
218
+ * apart are asking a different question than "what is allowed here".
219
+ */
220
+ function cspSourcesFor(policy, directive) {
221
+ const segment = policy
222
+ .split(';')
223
+ .map((entry) => entry.trim())
224
+ .find((entry) => entry === directive || entry.startsWith(`${directive} `));
225
+ return segment === undefined ? [] : segment.split(/\s+/).slice(1);
226
+ }
227
+ /**
228
+ * Index of the `>` that closes a tag whose attribute region starts at `from`,
229
+ * or `-1` if the document ends first. Quote-aware: a `>` inside an attribute
230
+ * VALUE does not close the tag.
231
+ *
232
+ * No HTML any Oxy build currently emits contains such an attribute, so this is
233
+ * not load-bearing today — it is here because the same scanner reads the SERVED
234
+ * document in the post-deploy gate, and what an edge injects into that document
235
+ * is not ours to constrain. Getting it wrong is not a parse error: the body
236
+ * window shifts, the hash is computed over the wrong bytes, and the script is
237
+ * blocked exactly as if no hash had been derived at all.
238
+ */
239
+ function findTagEnd(html, from) {
240
+ let quote = null;
241
+ for (let index = from; index < html.length; index += 1) {
242
+ const character = html[index];
243
+ if (quote !== null) {
244
+ if (character === quote)
245
+ quote = null;
246
+ continue;
247
+ }
248
+ if (character === '"' || character === "'") {
249
+ quote = character;
250
+ continue;
251
+ }
252
+ if (character === '>')
253
+ return index;
254
+ }
255
+ return -1;
256
+ }
257
+ /**
258
+ * Every inline `<script>` body in an HTML document, in document order. A
259
+ * `<script src=…>` is a URL the source list already governs and is skipped.
260
+ *
261
+ * Scanned rather than matched with one regex because the two failure modes are
262
+ * not symmetric: an EXTRA body costs a redundant hash nobody notices, while a
263
+ * MISSED body silently reinstates the exact breakage this exists to prevent.
264
+ * So the scan errs toward finding them — it walks the open tag quote-aware
265
+ * instead of letting a `>` inside an attribute value truncate it.
266
+ *
267
+ * The type attribute is deliberately not consulted. Whether a given `type`
268
+ * executes is a browser decision (and it changes: `importmap` and
269
+ * `speculationrules` were both once inert), and pinning the exact bytes of a
270
+ * data block we ship ourselves weakens nothing.
271
+ */
272
+ function extractInlineScripts(html) {
273
+ const lowered = html.toLowerCase();
274
+ const bodies = [];
275
+ const openTag = /<script\b/gi;
276
+ let match = openTag.exec(html);
277
+ while (match !== null) {
278
+ const attributesStart = match.index + match[0].length;
279
+ const attributesEnd = findTagEnd(html, attributesStart);
280
+ if (attributesEnd < 0)
281
+ break;
282
+ const bodyStart = attributesEnd + 1;
283
+ const bodyEnd = lowered.indexOf('</script', bodyStart);
284
+ if (bodyEnd < 0)
285
+ break;
286
+ if (!/\bsrc\s*=/i.test(html.slice(attributesStart, attributesEnd))) {
287
+ bodies.push(html.slice(bodyStart, bodyEnd));
288
+ }
289
+ openTag.lastIndex = bodyEnd;
290
+ match = openTag.exec(html);
291
+ }
292
+ return bodies;
293
+ }
294
+ /**
295
+ * The `'sha256-…'` source that allows one inline script, hashed over its exact
296
+ * bytes as CSP specifies — no trimming, no normalization. One byte of
297
+ * whitespace either way is a different hash and the script stays blocked.
298
+ */
299
+ function inlineScriptCspHash(source) {
300
+ return `'sha256-${(0, node_crypto_1.createHash)('sha256').update(source, 'utf8').digest('base64')}'`;
301
+ }
302
+ /**
303
+ * Ceiling on how many derived inline-script hashes may enter one `_headers`
304
+ * block. Nothing in an Oxy app authors an inline script, so the realistic
305
+ * count is the ONE Expo Router hydration flag — deduped across every route's
306
+ * HTML, because it is byte-identical in all of them.
307
+ *
308
+ * The ceiling exists because one future change breaks that: a route loader
309
+ * makes Expo emit a SECOND inline script, `__EXPO_ROUTER_LOADER_DATA__`, whose
310
+ * bytes differ per route. The hashes stay CORRECT (they are derived from the
311
+ * same build that ships), but the count becomes the route count and the policy
312
+ * grows without bound on every response. That is a decision to take
313
+ * deliberately, so it arrives as a red build rather than a quietly enormous
314
+ * header.
315
+ */
316
+ const MAX_INLINE_SCRIPT_HASHES = 8;
189
317
  /**
190
318
  * Build a Cloudflare Pages `_headers` block for an Oxy HTML origin. Uses the
191
319
  * same CSP resolution as {@link createOxySecurityHeaders} plus the non-CSP
192
320
  * hardening headers Helmet would add on an Express HTML backend.
321
+ *
322
+ * Adding a hash to `script-src` does not narrow it: per CSP Level 3 a hash is
323
+ * an additional source, so `'self'` and the beacon host keep matching external
324
+ * scripts. (It WOULD neutralize `'unsafe-inline'` in the same directive — which
325
+ * is why this hashes scripts only. `style-src` keeps `'unsafe-inline'` for
326
+ * react-native-web's runtime stylesheet, and a style hash would silently switch
327
+ * that off and render every Oxy web app unstyled.)
193
328
  */
194
329
  function buildOxyPagesHeaders(options = {}) {
195
- const csp = formatOxyCspPolicy(buildOxyCspDirectives(options.csp));
330
+ const hashes = [
331
+ ...new Set((options.html ?? []).flatMap(extractInlineScripts).map(inlineScriptCspHash)),
332
+ ];
333
+ if (hashes.length > MAX_INLINE_SCRIPT_HASHES) {
334
+ 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.`);
335
+ }
336
+ const csp = formatOxyCspPolicy(buildOxyCspDirectives(hashes.length === 0
337
+ ? options.csp
338
+ : { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] }));
196
339
  const lines = [
197
340
  '/*',
198
341
  ` Content-Security-Policy: ${csp}`,