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