@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/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).
|