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