@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.
@@ -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
- * Build the origin-matching predicate: true iff `origin` is in the built-in
78
- * HTTPS Oxy apex family OR exactly matches one of the configured app origins.
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 buildOriginAllowed(appOrigins) {
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
- explicit.add(normalized);
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 (origin) => {
88
- const normalized = normalizeOrigin(origin);
89
- if (normalized === null)
90
- return false;
91
- if (explicit.has(normalized))
92
- return true;
93
- return isOxyFamilyOrigin(normalized);
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 isOriginAllowed = buildOriginAllowed(appOrigins);
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 (!isOriginAllowed(origin)) {
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).