@oxyhq/core 21.0.1 → 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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/mixins/OxyServices.utility.js +34 -1
- package/dist/cjs/server/cors.js +84 -15
- 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/cors.js +82 -15
- 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 +16 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +24 -2
- package/dist/types/server/cors.d.ts +41 -0
- 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 +16 -0
- package/src/mixins/OxyServices.utility.ts +46 -3
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/server/__tests__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/securityHeaders.test.ts +116 -12
- package/src/server/cors.ts +87 -12
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +164 -1
|
@@ -84,7 +84,26 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
84
84
|
return cached.result;
|
|
85
85
|
}
|
|
86
86
|
try {
|
|
87
|
-
|
|
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
|
*
|
package/dist/cjs/server/cors.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
17
17
|
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
18
18
|
* - allows the caller's explicit `appOrigins`,
|
|
19
|
+
* - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
|
|
19
20
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
21
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
21
22
|
* sets `Vary: Origin` for correct caching,
|
|
@@ -24,8 +25,12 @@
|
|
|
24
25
|
* Node/Express-only: exported solely from `@oxyhq/core/server`.
|
|
25
26
|
*/
|
|
26
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.normalizeAppOrigins = normalizeAppOrigins;
|
|
29
|
+
exports.matchesAllowedOrigin = matchesAllowedOrigin;
|
|
27
30
|
exports.createOxyCors = createOxyCors;
|
|
31
|
+
const logger_1 = require("../logger");
|
|
28
32
|
const authWebUrl_1 = require("../utils/authWebUrl");
|
|
33
|
+
const log = (0, logger_1.createLogger)('OxyCors');
|
|
29
34
|
/** Default HTTP methods allowed across origins. */
|
|
30
35
|
const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
|
|
31
36
|
/** Default request headers a browser may send on a credentialed cross-origin call. */
|
|
@@ -64,6 +69,29 @@ function isOxyFamilyOrigin(candidate) {
|
|
|
64
69
|
return false;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The URL standard's serialization of an OPAQUE origin: the literal string
|
|
74
|
+
* `"null"`, which `new URL(x).origin` returns for every scheme that has no
|
|
75
|
+
* origin to speak of — `exp:`, `capacitor:`, `chrome-extension:`,
|
|
76
|
+
* `vscode-webview:`, and also `file:`, `data:` and `about:`.
|
|
77
|
+
*
|
|
78
|
+
* This value is why an allowlist may never store it. Every such scheme
|
|
79
|
+
* normalizes to the SAME `"null"`, so a set built by normalization cannot tell
|
|
80
|
+
* them apart: ONE opaque entry admits ALL of them. With credentials on and the
|
|
81
|
+
* raw header echoed back, a single `myapp://` in `appOrigins` turned this
|
|
82
|
+
* helper into "allow any custom-scheme browsing context" — measured live, an
|
|
83
|
+
* `exp://localhost:8150` entry answered `Origin: vscode-webview://…` with
|
|
84
|
+
* `access-control-allow-origin: vscode-webview://…` and
|
|
85
|
+
* `access-control-allow-credentials: true`.
|
|
86
|
+
*
|
|
87
|
+
* There is deliberately no escape hatch that matches such an origin by raw
|
|
88
|
+
* string instead. Admitting a custom-scheme browsing context to a CREDENTIALED
|
|
89
|
+
* allowlist is a distinct decision with its own threat model, and it must not
|
|
90
|
+
* arrive as a side effect of someone adding one line to `appOrigins`. Note
|
|
91
|
+
* also that a native client is not subject to CORS at all — React Native sends
|
|
92
|
+
* no `Origin` header — so a mobile app never needs an entry here.
|
|
93
|
+
*/
|
|
94
|
+
const OPAQUE_ORIGIN = 'null';
|
|
67
95
|
/** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
|
|
68
96
|
function normalizeOrigin(raw) {
|
|
69
97
|
try {
|
|
@@ -74,24 +102,65 @@ function normalizeOrigin(raw) {
|
|
|
74
102
|
}
|
|
75
103
|
}
|
|
76
104
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
105
|
+
* Normalize the configured `appOrigins` into the exact-match set — the
|
|
106
|
+
* CONFIGURE-SIDE half of the opaque-origin guard.
|
|
107
|
+
*
|
|
108
|
+
* An entry that is not a URL, or whose origin is opaque, is dropped and named
|
|
109
|
+
* in an error log. Dropped rather than thrown on because `appOrigins` is
|
|
110
|
+
* deployment configuration — at least one Oxy backend reads it from the
|
|
111
|
+
* environment — and a typo there must cost that one origin its CORS headers,
|
|
112
|
+
* never the whole service its boot. Both failure modes are equally SAFE (the
|
|
113
|
+
* entry is absent from the set either way), so the choice is purely about
|
|
114
|
+
* blast radius, and dropping keeps it to one origin whose requests then fail
|
|
115
|
+
* visibly in the browser.
|
|
116
|
+
*
|
|
117
|
+
* Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
|
|
118
|
+
* `server/index.ts`, so it is not part of the package's public surface. The
|
|
119
|
+
* two halves of the guard are separately exported because they are separately
|
|
120
|
+
* testable only that way: with this half in place the match-side half is
|
|
121
|
+
* unreachable through `createOxyCors`, so a test driving the public API alone
|
|
122
|
+
* would measure this function twice and the other one never.
|
|
79
123
|
*/
|
|
80
|
-
function
|
|
124
|
+
function normalizeAppOrigins(appOrigins) {
|
|
81
125
|
const explicit = new Set();
|
|
82
126
|
for (const raw of appOrigins) {
|
|
83
127
|
const normalized = normalizeOrigin(raw);
|
|
84
|
-
if (normalized)
|
|
85
|
-
|
|
128
|
+
if (normalized === null) {
|
|
129
|
+
log.error('CORS allowlist entry ignored: it is not a URL', undefined, { entry: raw });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (normalized === OPAQUE_ORIGIN) {
|
|
133
|
+
log.error('CORS allowlist entry ignored: it has no origin to match against', undefined, {
|
|
134
|
+
entry: raw,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
explicit.add(normalized);
|
|
86
139
|
}
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
140
|
+
return explicit;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
|
|
144
|
+
* family, or it exactly matches one of the configured app origins.
|
|
145
|
+
*
|
|
146
|
+
* The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
|
|
147
|
+
* is what makes the property hold regardless of how `explicit` was built — a
|
|
148
|
+
* set that somehow contains `"null"` still matches nothing, because no
|
|
149
|
+
* incoming origin ever normalizes past this line. `normalizeAppOrigins` is
|
|
150
|
+
* what stops such a set existing today; this is what stops it mattering.
|
|
151
|
+
*
|
|
152
|
+
* Exported for the same reason as `normalizeAppOrigins`, and likewise absent
|
|
153
|
+
* from `server/index.ts`.
|
|
154
|
+
*/
|
|
155
|
+
function matchesAllowedOrigin(explicit, origin) {
|
|
156
|
+
const normalized = normalizeOrigin(origin);
|
|
157
|
+
if (normalized === null)
|
|
158
|
+
return false;
|
|
159
|
+
if (normalized === OPAQUE_ORIGIN)
|
|
160
|
+
return false;
|
|
161
|
+
if (explicit.has(normalized))
|
|
162
|
+
return true;
|
|
163
|
+
return isOxyFamilyOrigin(normalized);
|
|
95
164
|
}
|
|
96
165
|
/**
|
|
97
166
|
* Create a strict Oxy CORS middleware. See module docs.
|
|
@@ -103,7 +172,7 @@ function buildOriginAllowed(appOrigins) {
|
|
|
103
172
|
*/
|
|
104
173
|
function createOxyCors(options = {}) {
|
|
105
174
|
const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
|
|
106
|
-
const
|
|
175
|
+
const explicitOrigins = normalizeAppOrigins(appOrigins);
|
|
107
176
|
const methodsHeader = methods.join(', ');
|
|
108
177
|
const allowedHeadersHeader = allowedHeaders.join(', ');
|
|
109
178
|
const exposedHeadersHeader = exposedHeaders.join(', ');
|
|
@@ -121,7 +190,7 @@ function createOxyCors(options = {}) {
|
|
|
121
190
|
}
|
|
122
191
|
// Origin is present. Caching correctness: this response varies by Origin.
|
|
123
192
|
res.setHeader('Vary', 'Origin');
|
|
124
|
-
if (!
|
|
193
|
+
if (!matchesAllowedOrigin(explicitOrigins, origin)) {
|
|
125
194
|
// DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
|
|
126
195
|
// will block the cross-origin read. Preflights for denied origins get a
|
|
127
196
|
// 204 with no CORS headers (the actual request then fails CORS).
|
package/dist/cjs/server/index.js
CHANGED
|
@@ -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
|
|
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}`,
|