diff --git a/.changeset/24809-cors-doors-one-admission-rule.md b/.changeset/24809-cors-doors-one-admission-rule.md new file mode 100644 index 00000000000..65f6c434583 --- /dev/null +++ b/.changeset/24809-cors-doors-one-admission-rule.md @@ -0,0 +1,23 @@ +--- +'@objectstack/plugin-hono-server': minor +'@objectstack/hono': minor +'@objectstack/plugin-auth': minor +--- + +fix(cors)!: every CORS door reads one admission rule; a loopback origin beside a list is admitted on a development boot only; an all-host pattern is refused with credentials, as `'*'` is + +Clause-②: yes (narrowing) + + + +**BREAKING** — an admission narrowing at three doors, shipped as `minor` under the launch-window convention for accept-set narrowings. The governing text is `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an explicit origin list. It finishes what the `HonoServerPlugin` default change that `18.0.0` shipped (#24738) began. + +- **One rule.** `@objectstack/plugin-hono-server` exports `resolveCorsAdmission`, and both transport doors read it: `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) and `@objectstack/hono`'s `createHonoApp`. One configuration now gets one answer at both. That one rule shared by two published doors is why the public surface grows: `resolveCorsAdmission` with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`, `isAllHostOriginPattern`, `OriginMatcherOptions`, and an optional second parameter on `createOriginMatcher`. +- **`createHonoApp`, before.** With no origin list (no `cors.origin` option and no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests, and a configured `'*'` with credentials on was accepted the same way. **Now** it answers as `HonoServerPlugin` does: no list admits no cross-origin caller (a loopback origin excepted, on a development boot), and `'*'` with credentials on is refused when the app is created. +- **`createHonoApp`'s `cors.origin` as one comma-separated string, before.** A `cors.origin` written as one comma-separated string of exact origins (`'https://a.example.com,https://b.example.com'`) admitted no origin. **Now** each listed origin is admitted, as `OS_CORS_ORIGIN` written the same way already was: a widening only toward what the operator listed, which the option's TSDoc already promised ("Comma-separated string or array"). +- **Loopback beside a list, before.** A configured list also admitted every loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port), in every mode, at both doors that used the pattern matcher. **Now** that happens on a development boot only (`NODE_ENV` exactly `development`, read through `isDevelopmentBoot` from `@objectstack/core`). Elsewhere a list admits what it names; a list that names `http://localhost:*` still admits it. One door gains on a development boot: `createHonoApp` with a list of exact origins never admitted a loopback origin, and now does there, as `HonoServerPlugin` already did. `matchOriginPattern` now matches the pattern and nothing else, and `createOriginMatcher` takes an optional `{ developmentBoot }`, read from `NODE_ENV` when the matcher is created if it is not passed, so a consumer that applies the matcher outside the Hono pipeline answers as the doors do on the same boot. +- **All-host patterns, before.** Only a literal `'*'` was refused with credentials. **Now** a pattern whose host part is the bare `*` (`https://*`, `*://*`, `http://*:*`; `isAllHostOriginPattern`) is refused with credentials at both transport doors exactly as `'*'` is, and the message names the entry. +- **Auth's trusted origins, before.** `@objectstack/plugin-auth` tested the whole `OS_CORS_ORIGIN` value against `'*'` and then split it, so a `'*'` entry among real origins, or an all-host pattern, entered better-auth's trusted origins. **Now** each entry is judged on its own, and an entry that admits every host never enters the trusted set. +- **Unchanged.** Same-origin requests need no CORS answer. An exact origin or a pattern that names part of the host (`https://*.example.com`) is admitted as before, credentials included; the one exception is the comma-separated `cors.origin` string above, which now admits what it lists. `'*'` with credentials off is still served. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Headers, methods and max-age are unchanged. `OS_TRUSTED_ORIGINS` still admits nothing to CORS. + +**What an operator does.** A host composing `createHonoApp` for a cross-origin front-end names its origins, in `OS_CORS_ORIGIN` or the `cors.origin` option. A deployment that reached the server from a loopback origin outside development names that origin in its list. A list holding `'*'` or an all-host pattern while credentials are on either names its origins or sets `OS_CORS_CREDENTIALS=false`. diff --git a/docs/HARDENING.md b/docs/HARDENING.md index 0645357253f..e0871b56698 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -200,8 +200,8 @@ origins you name: | Configuration | What is admitted | | --- | --- | | No origin list (neither `OS_CORS_ORIGIN` nor the plugin's `cors.origins`) | **No cross-origin caller.** On a development boot (`NODE_ENV=development`, which `os dev` and `os serve --dev` set), loopback origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too. | -| A list (`OS_CORS_ORIGIN=https://app.example.com`, comma-separated; exact origins or patterns such as `https://*.example.com`) | Those origins, with credentials unless `OS_CORS_CREDENTIALS=false`. Loopback origins are admitted alongside any list. | -| `*` while credentials are on | **Refused at boot** — the boot fails with a message naming the two remedies. | +| A list (`OS_CORS_ORIGIN=https://app.example.com`, comma-separated; exact origins or patterns such as `https://*.example.com`) | Those origins, with credentials unless `OS_CORS_CREDENTIALS=false`. On a development boot, loopback origins are admitted beside the list too; on any other boot, only what the list names. | +| `*`, or a pattern whose host part is the bare `*` (`https://*`, `*://*`), while credentials are on | **Refused at boot** — the boot fails with a message naming the entry and the two remedies. | Same-origin requests need no CORS answer, so the Console under `/_console` and any page the server itself serves work with no list at @@ -219,7 +219,10 @@ variable covers a cross-origin front-end that signs in. A list given through the plugin's `cors.origins` option instead is not seen by auth, so such a front-end is then also named in `OS_TRUSTED_ORIGINS`. -Composing the Hono adapter yourself, name the origins there as well: +Composing the Hono adapter yourself, `createHonoApp` answers by the +same rule — the table above holds for it too, with its own `cors.origin` +option in place of `cors.origins` — so with no list it admits no +cross-origin caller. Name the origins there: ```ts import { createHonoApp } from '@objectstack/hono'; @@ -235,9 +238,12 @@ const app = createHonoApp({ ``` Do **not** use `origin: '*'` with `credentials: true`: together they -admit every origin for credentialed requests. `HonoServerPlugin` -refuses the combination at boot; `*` is accepted only with credentials -off (`OS_CORS_CREDENTIALS=false`), for an API that takes no cookies. +admit every origin for credentialed requests. Both doors refuse the +combination, and a pattern whose host part is the bare `*` with it — +`HonoServerPlugin` at boot, `createHonoApp` when the app is created; +such an entry is accepted only with credentials off +(`OS_CORS_CREDENTIALS=false`), for an API that takes no cookies, and +auth never trusts it from `OS_CORS_ORIGIN`. ## Auditing diff --git a/packages/adapters/hono/README.md b/packages/adapters/hono/README.md index 8ae81ce90e8..908f7baaf52 100644 --- a/packages/adapters/hono/README.md +++ b/packages/adapters/hono/README.md @@ -33,6 +33,16 @@ const app = createHonoApp({ export default app; // Cloudflare Workers / Bun / Deno / Vercel Edge ``` +### CORS + +`createHonoApp` admits by the same rule as `@objectstack/plugin-hono-server`'s +`HonoServerPlugin` (`resolveCorsAdmission`), so one configuration gets one answer at +both: a list admits what it names; no list admits no cross-origin caller; a loopback +origin is admitted, beside a list or without one, on a development boot only +(`NODE_ENV=development`). `'*'`, or a pattern whose host part is the bare `*` +(`https://*`), while credentials are on is refused when the app is created; turn +credentials off to serve it to callers that send no cookies. + ### Embed into an existing Hono app ```typescript @@ -59,8 +69,8 @@ app.use('/api/*', objectStackMiddleware(kernel)); | `kernel` | `ObjectKernel` | — | Bootstrapped kernel. | | `prefix` | `string` | `'/api'` | Base path. | | `cors` | `ObjectStackHonoCorsOptions \| false` | Enabled | Set to `false` to disable. | -| `cors.origin` | `string \| string[]` | env `CORS_ORIGIN` or `'*'` | Supports wildcard patterns. | -| `cors.credentials` | `boolean` | `false` | Required for cookie sessions. | +| `cors.origin` | `string \| string[]` | env `OS_CORS_ORIGIN`, else no list | Supports wildcard patterns. With no list, no cross-origin caller is admitted. | +| `cors.credentials` | `boolean` | env `OS_CORS_CREDENTIALS !== 'false'` (on) | Required for cookie sessions. | | `cors.exposeHeaders` | `string[]` | `['set-auth-token']` merged | Always includes `set-auth-token` for better-auth. | ## Edge runtime notes diff --git a/packages/adapters/hono/src/hono-cors-admission.test.ts b/packages/adapters/hono/src/hono-cors-admission.test.ts new file mode 100644 index 00000000000..573e3c6bf52 --- /dev/null +++ b/packages/adapters/hono/src/hono-cors-admission.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What `createHonoApp`'s CORS answer ADMITS — through the real `hono/cors` + * middleware (`app.request`), never a captured config. + * + * The governing text is `docs/HARDENING.md` § CORS: "Do **not** use + * `origin: '*'` with `credentials: true`", and the go-live checklist's "CORS + * origin list is explicit, not `*`". This door reads the same rule + * `HonoServerPlugin` does (`resolveCorsAdmission` in + * `@objectstack/plugin-hono-server`, which this package's vitest config + * resolves to SOURCE), so a configuration gets the same answer here as there: + * + * 1. no origin list (neither `cors.origin` nor `OS_CORS_ORIGIN`): no + * cross-origin caller is admitted; a loopback origin is admitted on a + * development boot only; + * 2. a configured list is admitted with credentials, and nothing else is — a + * loopback origin beside it on a development boot only; + * 3. `'*'`, or a pattern whose host part is the bare `*`, together with + * credentials is refused when the app is created; without credentials it + * is served. + * + * "Admitted" is read off `Access-Control-Allow-Origin` — the one header a + * browser checks before handing a cross-origin response to the page. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Hono } from 'hono'; + +// `@objectstack/runtime` resolves to `src/__mocks__/runtime.ts` (this +// package's vitest alias): the dispatcher is never reached on `/probe`. +import { createHonoApp, type ObjectStackHonoOptions } from './index'; + +const FOREIGN = 'https://other.example'; +const CONFIGURED = 'https://app.example.com'; +const LOOPBACK = 'http://localhost:5173'; + +/** Every variable the CORS block reads, canonical and legacy spellings. */ +const CORS_ENV = [ + 'OS_CORS_ENABLED', 'CORS_ENABLED', + 'OS_CORS_ORIGIN', 'CORS_ORIGIN', + 'OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', + 'OS_CORS_MAX_AGE', 'CORS_MAX_AGE', + 'NODE_ENV', +] as const; + +let saved: Record = {}; + +beforeEach(() => { + saved = {}; + for (const name of CORS_ENV) { + saved[name] = process.env[name]; + delete process.env[name]; + } + // Not a development boot unless a case says so. + process.env.NODE_ENV = 'production'; +}); + +afterEach(() => { + for (const name of CORS_ENV) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } +}); + +const kernel = { name: 'cors-admission-kernel' } as unknown as ObjectStackHonoOptions['kernel']; + +/** The real `createHonoApp`, with one probe route behind its middleware. */ +function app(cors?: ObjectStackHonoOptions['cors']): Hono { + const created = createHonoApp({ kernel, prefix: '/api/v1', ...(cors === undefined ? {} : { cors }) }); + created.get('/probe', (c) => c.text('ok')); + return created; +} + +/** The `Access-Control-Allow-Origin` a credentialed GET from `origin` is answered with. */ +async function allowedOnGet(target: Hono, origin: string): Promise { + const res = await target.request('/probe', { headers: { Origin: origin, Cookie: 'session=1' } }); + expect(res.status).toBe(200); + return res.headers.get('access-control-allow-origin'); +} + +/** The same, for the preflight a browser sends ahead of a non-simple request. */ +async function allowedOnPreflight(target: Hono, origin: string): Promise { + const res = await target.request('/probe', { + method: 'OPTIONS', + headers: { + Origin: origin, + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Headers': 'content-type', + }, + }); + return res.headers.get('access-control-allow-origin'); +} + +/** What `createHonoApp` threw; an app that was created fails the case. */ +function refusal(cors?: ObjectStackHonoOptions['cors']): Error { + let thrown: unknown = null; + try { + app(cors); + } catch (e) { + thrown = e; + } + expect(thrown).toBeInstanceOf(Error); + return thrown as Error; +} + +describe('1. with no origin list configured, no cross-origin caller is admitted', () => { + it('answers a foreign origin with no Access-Control-Allow-Origin, on a request and on a preflight', async () => { + const target = app(); + expect(await allowedOnGet(target, FOREIGN)).toBeNull(); + expect(await allowedOnPreflight(target, FOREIGN)).toBeNull(); + }); + + it('does not admit a loopback origin either, outside a development boot', async () => { + const target = app(); + expect(await allowedOnGet(target, LOOPBACK)).toBeNull(); + expect(await allowedOnPreflight(target, LOOPBACK)).toBeNull(); + }); + + it('admits a loopback origin, and still no foreign one, on a development boot', async () => { + process.env.NODE_ENV = 'development'; + const target = app(); + expect(await allowedOnGet(target, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnGet(target, FOREIGN)).toBeNull(); + }); +}); + +describe('2. a configured list is admitted with credentials, and nothing else is', () => { + it('via the `cors.origin` option', async () => { + const target = app({ origin: [CONFIGURED] }); + const res = await target.request('/probe', { headers: { Origin: CONFIGURED } }); + expect(res.headers.get('access-control-allow-origin')).toBe(CONFIGURED); + expect(res.headers.get('access-control-allow-credentials')).toBe('true'); + expect(await allowedOnGet(target, FOREIGN)).toBeNull(); + }); + + it('via OS_CORS_ORIGIN, comma-separated, patterns included', async () => { + process.env.OS_CORS_ORIGIN = `${CONFIGURED}, https://*.example.net`; + const target = app(); + expect(await allowedOnGet(target, CONFIGURED)).toBe(CONFIGURED); + expect(await allowedOnGet(target, 'https://tenant.example.net')).toBe('https://tenant.example.net'); + expect(await allowedOnGet(target, FOREIGN)).toBeNull(); + }); + + it('admits a loopback origin beside the list on a development boot only', async () => { + for (const origin of [[CONFIGURED], 'https://*.example.net']) { + process.env.NODE_ENV = 'production'; + expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBeNull(); + process.env.NODE_ENV = 'development'; + expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBe(LOOPBACK); + } + }); +}); + +describe("3. '*', or a pattern whose host part is the bare `*`, with credentials is refused when the app is created", () => { + it('from the `cors.origin` option, with credentials left at their default, naming the entry', () => { + for (const entry of ['*', 'https://*', '*://*', 'http://*:*']) { + const err = refusal({ origin: entry }); + expect(err.message).toContain(`'${entry}'`); + expect(err.message).toContain('OS_CORS_ORIGIN'); + expect(err.message).toContain('OS_CORS_CREDENTIALS'); + } + }); + + it('from OS_CORS_ORIGIN, alone or inside a list', () => { + for (const value of ['*', ' * ', `${CONFIGURED},*`, 'https://*', `${CONFIGURED}, *://*`]) { + process.env.OS_CORS_ORIGIN = value; + refusal(); + } + }); + + it('with credentials named explicitly, by option or by OS_CORS_CREDENTIALS', () => { + refusal({ origin: ['*'], credentials: true }); + process.env.OS_CORS_ORIGIN = 'https://*'; + process.env.OS_CORS_CREDENTIALS = 'true'; + refusal(); + }); + + it('serves them without credentials, admitting any origin with no credentials header', async () => { + process.env.OS_CORS_CREDENTIALS = 'false'; + for (const value of ['*', 'https://*']) { + process.env.OS_CORS_ORIGIN = value; + const res = await app().request('/probe', { headers: { Origin: FOREIGN } }); + expect(res.headers.get('access-control-allow-origin')).toBe(FOREIGN); + expect(res.headers.get('access-control-allow-credentials')).toBeNull(); + } + }); +}); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 62e6f29d401..bc08bee77ff 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -41,8 +41,7 @@ export type KernelManager = any; */ export type EnvironmentDriverRegistry = any; import { - createOriginMatcher, - hasWildcardPattern, + resolveCorsAdmission, DEFAULT_CORS_ALLOW_HEADERS, DEFAULT_CORS_EXPOSE_HEADERS, declaredEnvelopeForThrow, @@ -51,11 +50,25 @@ import { export interface ObjectStackHonoCorsOptions { /** Enable or disable CORS. Defaults to true. */ enabled?: boolean; - /** Allowed origins. Defaults to env `OS_CORS_ORIGIN` (or legacy `CORS_ORIGIN`) or '*'. Comma-separated string or array. */ + /** + * The origins admitted to cross-origin requests: exact origins and wildcard + * patterns (`https://*.example.com`), as an array or one comma-separated + * string. Wins over `OS_CORS_ORIGIN` (or legacy `CORS_ORIGIN`). + * + * No default list. With neither set, no cross-origin caller is admitted. A + * loopback origin (`localhost`, `127.0.0.1`, `[::1]`, any port) is admitted + * beside the list, or without one, on a development boot only + * (`NODE_ENV=development`). + * + * `'*'`, or a pattern whose host part is the bare `*` (`https://*`), is + * refused by {@link createHonoApp} while credentials are on; it is served + * with credentials off. The rule is `@objectstack/plugin-hono-server`'s + * `resolveCorsAdmission`, the one `HonoServerPlugin` reads. + */ origin?: string | string[]; /** Allowed methods. */ methods?: string[]; - /** Allow credentials (cookies, authorization headers). */ + /** Allow credentials (cookies, authorization headers). Defaults to `OS_CORS_CREDENTIALS !== 'false'`, i.e. on. */ credentials?: boolean; /** Preflight cache max-age in seconds. */ maxAge?: number; @@ -607,55 +620,33 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // ─── CORS Middleware ────────────────────────────────────────────────────── // Enabled by default. Controlled via options.cors or environment variables: // OS_CORS_ENABLED – "false" to disable (default: true) - // OS_CORS_ORIGIN – comma-separated origins or "*" (default: "*") + // OS_CORS_ORIGIN – comma-separated origins (default: none) // OS_CORS_CREDENTIALS – "false" to disallow credentials (default: true) // OS_CORS_MAX_AGE – preflight cache seconds (default: 86400) // (legacy CORS_* names still honoured with a deprecation warning) + // + // What it ADMITS is `resolveCorsAdmission` from `@objectstack/plugin-hono-server` + // — the rule `HonoServerPlugin` reads — so one configuration gets one answer at + // both doors (`docs/HARDENING.md` § CORS): a list admits exactly that list, no + // list admits no cross-origin caller, a loopback origin is admitted on a + // development boot only, and an entry that admits every host (`'*'`, + // `https://*`) while credentials are on is refused HERE, when the app is + // created. Its answer is always a matcher function, which is also what keeps + // wildcard patterns working: Hono's `cors()` matches strings exactly. const corsDisabledByEnv = readEnvWithDeprecation('OS_CORS_ENABLED', 'CORS_ENABLED', { silent: true }) === 'false'; if (options.cors !== false && !corsDisabledByEnv) { const corsOpts = typeof options.cors === 'object' ? options.cors : {}; const enabled = corsOpts.enabled ?? true; if (enabled) { - // Resolve origins: options > env > default '*' - let configuredOrigin: string | string[]; - const corsOriginEnv = readEnvWithDeprecation('OS_CORS_ORIGIN', 'CORS_ORIGIN', { silent: true }); - if (corsOpts.origin) { - configuredOrigin = corsOpts.origin; - } else if (corsOriginEnv) { - const envOrigin = corsOriginEnv.trim(); - configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin; - } else { - configuredOrigin = '*'; - } - - const credentials = corsOpts.credentials ?? (readEnvWithDeprecation('OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', { silent: true }) !== 'false'); + const admission = resolveCorsAdmission({ + door: { name: '@objectstack/hono', originsOption: 'cors.origin' }, + origins: corsOpts.origin, + credentials: corsOpts.credentials, + }); const maxAgeEnv = readEnvWithDeprecation('OS_CORS_MAX_AGE', 'CORS_MAX_AGE', { silent: true }); const maxAge = corsOpts.maxAge ?? (maxAgeEnv ? parseInt(maxAgeEnv, 10) : 86400); - // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin. - // For wildcard patterns (like "https://*.example.com" or "http://localhost:*") we must - // use a matcher function — Hono's cors() middleware does exact-string matching only and - // treats '*' in patterns as a literal character, so passing wildcard strings straight - // through would silently drop the Access-Control-Allow-Origin header on every real - // request (preflight can still succeed via the short-circuit in `apps/objectos`, - // which lives in the separate `objectstack-ai/cloud` repo and is NOT a path in - // this one, but the subsequent POST/GET would be blocked by the browser). - // - // This mirrors `plugin-hono-server`'s CORS wiring and uses the shared pattern matcher - // from `@objectstack/plugin-hono-server` so all Hono-based code paths stay in sync. - let origin: string | string[] | ((origin: string) => string | undefined | null); - if (configuredOrigin === '*' && credentials) { - // Credentials mode with '*' — reflect the request origin - origin = (requestOrigin: string) => requestOrigin || '*'; - } else if (hasWildcardPattern(configuredOrigin)) { - // Wildcard patterns (e.g., "https://*.objectui.org", "http://localhost:*") - origin = createOriginMatcher(configuredOrigin); - } else { - // Exact origin(s) — pass through as-is - origin = configuredOrigin; - } - // Both CORS defaults are imported from `@objectstack/plugin-hono-server`, // which this package already depends on (#3786). The three Hono-based CORS // sites used to carry their own copies under "keep in sync" comments — this @@ -671,11 +662,11 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { ])); app.use('*', cors({ - origin: origin as any, + origin: admission.origin, allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], allowHeaders: corsOpts.allowHeaders || [...DEFAULT_CORS_ALLOW_HEADERS], exposeHeaders, - credentials, + credentials: admission.credentials, maxAge, })); } diff --git a/packages/plugins/plugin-auth/src/auth-manager.cors-origin-trust.test.ts b/packages/plugins/plugin-auth/src/auth-manager.cors-origin-trust.test.ts new file mode 100644 index 00000000000..e6c55164053 --- /dev/null +++ b/packages/plugins/plugin-auth/src/auth-manager.cors-origin-trust.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Which `OS_CORS_ORIGIN` entries reach better-auth's trusted origins. + * + * `AuthManager` trusts the origins listed in `OS_CORS_ORIGIN`, so one variable + * covers a cross-origin front-end that signs in (`docs/HARDENING.md` § CORS). + * The governing text is the same section: never `'*'` with credentials, and an + * explicit list. So each entry is judged on its own, and an entry that admits + * every host — `'*'`, or a pattern whose host part is the bare `*` — never + * enters the trusted set, however the list around it is spelled. That is the + * same class the transport refuses with credentials (`isAllHostOriginPattern` + * in `@objectstack/plugin-hono-server`); `@objectstack/http-conformance` holds + * the doors to one answer. + * + * Read off a REAL `betterAuth()` instance: `options.trustedOrigins` is what + * `createAuthInstance` actually handed it. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthManager } from './auth-manager'; +import type { AuthManagerOptions } from './auth-manager'; + +const CONFIGURED = 'https://app.example.com'; +const ENV_KEYS = ['NODE_ENV', 'OS_CORS_ORIGIN', 'CORS_ORIGIN', 'OS_SSO_ENABLED'] as const; + +let saved: Record = {}; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; + // Production: no development substitution, so the set is exactly what the + // configuration contributed. + process.env.NODE_ENV = 'production'; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]!; + } +}); + +/** The trusted origins a real better-auth instance was configured with (`[]` when the key is omitted). */ +async function trustedWith(corsOrigin: string): Promise { + process.env.OS_CORS_ORIGIN = corsOrigin; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'x'.repeat(40), + baseUrl: CONFIGURED, + } as unknown as AuthManagerOptions); + const auth = await manager.getAuthInstance(); + const trusted = auth.options.trustedOrigins; + expect(typeof trusted === 'function').toBe(false); + return (trusted as string[] | undefined) ?? []; + } finally { + warn.mockRestore(); + } +} + +describe('OS_CORS_ORIGIN entries reach the trusted set one by one', () => { + it("a '*' entry beside real origins does not enter the trusted set; the real origins do", async () => { + expect(await trustedWith(`${CONFIGURED},*`)).toEqual([CONFIGURED]); + expect(await trustedWith(`*, ${CONFIGURED}`)).toEqual([CONFIGURED]); + }); + + it("a '*' spelled with surrounding space is judged as '*'", async () => { + expect(await trustedWith(' * ')).toEqual([]); + expect(await trustedWith(`${CONFIGURED}, * `)).toEqual([CONFIGURED]); + }); + + it('a pattern whose host part is the bare `*` is judged as `*`', async () => { + for (const entry of ['https://*', '*://*', 'http://*:*', 'https://*:8443']) { + expect(await trustedWith(`${CONFIGURED},${entry}`)).toEqual([CONFIGURED]); + } + }); + + it('a pattern that names part of the host still enters, verbatim', async () => { + expect(await trustedWith(`${CONFIGURED},https://*.example.net`)).toEqual([ + CONFIGURED, + 'https://*.example.net', + ]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 990326f6db4..6ea7f38a7a7 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3014,11 +3014,20 @@ export class AuthManager { // Auto-includes origins from OS_CORS_ORIGIN env var so CORS and CSRF stay in sync. ...(() => { const origins: string[] = [...(this.config.trustedOrigins || [])]; - // Sync with OS_CORS_ORIGIN env var (comma-separated) + // Sync with OS_CORS_ORIGIN env var (comma-separated), judged ENTRY BY + // ENTRY: an entry that admits every host never enters the trusted set, + // however the list around it is spelled. That is `*`, or a pattern whose + // host part is the bare `*` (`https://*`, `*://*`, `http://*:*`) — + // better-auth's own matcher reads each of them as every origin — the + // same narrow class the CORS transport refuses with credentials + // (`isAllHostOriginPattern`, `@objectstack/plugin-hono-server`, which + // this package does not depend on). `docs/HARDENING.md` § CORS governs; + // `@objectstack/http-conformance`'s CORS-doors pin holds the two equal. const corsOrigin = readEnvWithDeprecation('OS_CORS_ORIGIN', 'CORS_ORIGIN', { silent: true }); - if (corsOrigin && corsOrigin !== '*') { + if (corsOrigin) { + const admitsEveryHost = (entry: string) => /^(?:[^:/]+:\/\/)?\*+(?::(?:\d+|\*))?$/.test(entry); corsOrigin.split(',').map(s => s.trim()).filter(Boolean).forEach(o => { - if (!origins.includes(o)) origins.push(o); + if (!admitsEveryHost(o) && !origins.includes(o)) origins.push(o); }); } // When CORS allows all origins (default) and no explicit trustedOrigins, diff --git a/packages/plugins/plugin-hono-server/README.md b/packages/plugins/plugin-hono-server/README.md index 67d6acfa8a7..ef6e71760f1 100644 --- a/packages/plugins/plugin-hono-server/README.md +++ b/packages/plugins/plugin-hono-server/README.md @@ -41,11 +41,12 @@ The CORS middleware is mounted by default, and it admits only the origins you na (`cors.origins`, else `OS_CORS_ORIGIN`). With neither set, **no cross-origin caller is admitted**. Same-origin requests need no CORS answer, so pages this server serves are unaffected. On a development boot (`NODE_ENV=development`, which `os dev` sets), loopback -origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too. +origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too, beside a list or +without one; on any other boot a list admits only what it names. -`'*'` while credentials are on (the default) is **refused at init**, so the boot fails. -Name the origins instead, or turn credentials off to serve `'*'` to callers that send no -cookies: +`'*'`, or a pattern whose host part is the bare `*` (`https://*`, `*://*`), while +credentials are on (the default) is **refused at init**, so the boot fails. Name the +origins instead, or turn credentials off to serve it to callers that send no cookies: ```typescript kernel.use(new HonoServerPlugin({ @@ -53,6 +54,11 @@ kernel.use(new HonoServerPlugin({ })); ``` +The rule is exported as `resolveCorsAdmission`, and `@objectstack/hono`'s `createHonoApp` +reads it too, so one configuration gets one answer at both doors. The matcher it uses, +`createOriginMatcher(patterns, { developmentBoot })`, reads the development boot from +`NODE_ENV` when the option is not passed. + ### Basic CORS ```typescript diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index c25de589cdd..07747f9d2ce 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -131,11 +131,13 @@ export interface HonoCorsOptions { * array or one comma-separated string. Wins over `OS_CORS_ORIGIN`. * * No default list. With neither this nor `OS_CORS_ORIGIN` set, no - * cross-origin caller is admitted — a loopback origin excepted, on a - * development boot only (`NODE_ENV=development`). Same-origin requests - * need no CORS answer and are unaffected either way. + * cross-origin caller is admitted. A loopback origin is admitted beside + * the list, or without one, on a development boot only + * (`NODE_ENV=development`). Same-origin requests need no CORS answer and + * are unaffected either way. * - * `'*'` (alone or in a list) is refused at init while + * `'*'`, or a pattern whose host part is the bare `*` (`https://*`, + * `*://*`), alone or in a list, is refused at init while * {@link HonoCorsOptions.credentials} is on; it is accepted with * credentials off. */ diff --git a/packages/plugins/plugin-hono-server/src/cors-admission.ts b/packages/plugins/plugin-hono-server/src/cors-admission.ts new file mode 100644 index 00000000000..f2da952a16c --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/cors-admission.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The CORS admission rule — the ONE answer to "which cross-origin callers does + * this door admit?", read by every door that mounts CORS: + * `HonoServerPlugin` (the server `os serve` / `os dev` / `os start` mount) and + * `@objectstack/hono`'s `createHonoApp`. Two doors that mount the same policy + * must answer the same configuration the same way, so the rule lives here once + * and each door only maps its own option names onto it. + * + * The governing text is `docs/HARDENING.md` § CORS: never `'*'` with + * credentials, and an explicit origin list. So the answer follows what was + * configured, and nothing else: + * + * - a list (the door's origin option, else `OS_CORS_ORIGIN`) — exactly that + * list, through {@link createOriginMatcher}; on a development boot a + * loopback origin is admitted beside it; + * - no list — no cross-origin caller. Nothing needs the host's own origin: a + * browser applies CORS to cross-origin requests only. On a development boot + * a loopback origin is admitted; + * - an entry that admits every host — `'*'`, or a pattern whose host part is + * the bare `*` ({@link isAllHostOriginPattern}) — while credentials are on: + * refused, by throwing, so the door's boot fails with the remedy named. + * + * "A development boot" is `isDevelopmentBoot` from `@objectstack/core` (`NODE_ENV` + * exactly `development`), the reading the auth secret's development fallback + * uses, read once when the rule is resolved. + */ + +import { isDevelopmentBoot } from '@objectstack/core'; +import { readEnvWithDeprecation } from '@objectstack/types'; + +import { + createOriginMatcher, + isAllHostOriginPattern, + isLocalhostOrigin, + normalizeOriginPatterns, +} from './pattern-matcher'; + +/** The door asking — named in the refusal, so the operator knows what to edit. */ +export interface CorsAdmissionDoor { + /** The package that mounts the middleware, e.g. `@objectstack/plugin-hono-server`. */ + readonly name: string; + /** That door's own origin-list option, e.g. `cors.origins`. */ + readonly originsOption: string; +} + +export interface CorsAdmissionInput { + readonly door: CorsAdmissionDoor; + /** The door's origin-list option. Wins over `OS_CORS_ORIGIN` when set. */ + readonly origins?: string | string[]; + /** The door's credentials option. Else `OS_CORS_CREDENTIALS !== 'false'`, i.e. on. */ + readonly credentials?: boolean; + /** Whether this is a development boot. Else `isDevelopmentBoot(undefined)`. */ + readonly developmentBoot?: boolean; +} + +export interface CorsAdmission { + /** + * `hono/cors`'s `origin`: the request origin to echo in + * `Access-Control-Allow-Origin`, or `null`. Always a function, so the + * middleware never sees a bare `'*'` of its own. + */ + readonly origin: (requestOrigin: string) => string | null; + /** Whether `Access-Control-Allow-Credentials: true` is sent. */ + readonly credentials: boolean; + /** What is admitted, for the door's boot log: the list, or the default's label. */ + readonly admits: string | string[]; +} + +/** + * Resolve what a door admits from its own options and the `OS_CORS_*` + * environment. Throws when an entry that admits every host meets credentials. + */ +export function resolveCorsAdmission(input: CorsAdmissionInput): CorsAdmission { + // `undefined` = nobody named a list: the default described above. + let configured: string | string[] | undefined; + if (input.origins) { + configured = input.origins; + } else { + const fromEnv = readEnvWithDeprecation('OS_CORS_ORIGIN', 'CORS_ORIGIN', { silent: true }); + if (fromEnv) configured = normalizeOriginPatterns(fromEnv); + } + + const credentials = input.credentials + ?? (readEnvWithDeprecation('OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', { silent: true }) !== 'false'); + const developmentBoot = input.developmentBoot ?? isDevelopmentBoot(undefined); + + if (configured === undefined) { + return { + origin: (requestOrigin: string) => + developmentBoot && isLocalhostOrigin(requestOrigin) ? requestOrigin : null, + credentials, + admits: developmentBoot ? 'loopback origins only (development boot)' : 'no cross-origin caller', + }; + } + + if (credentials) { + const everyHost = normalizeOriginPatterns(configured).find(isAllHostOriginPattern); + if (everyHost !== undefined) { + throw new Error( + `${input.door.name} refuses CORS origin '${everyHost}' with credentials: it admits every ` + + 'host, so together they admit every origin for credentialed cross-origin requests. Name the ' + + 'origins that may call with credentials (OS_CORS_ORIGIN=https://app.example.com, ' + + `comma-separated, or the \`${input.door.originsOption}\` option), or set ` + + 'OS_CORS_CREDENTIALS=false (or `cors.credentials: false`) to serve it without credentials.', + ); + } + } + + return { + origin: createOriginMatcher(configured, { developmentBoot }), + credentials, + admits: configured, + }; +} diff --git a/packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts b/packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts index 6f9aef00d1f..4d8ae04c1df 100644 --- a/packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts +++ b/packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts @@ -15,7 +15,15 @@ * 2. a configured origin is admitted, with credentials, and nothing else is; * 3. a loopback origin is admitted by the default on a development boot only; * 4. `'*'` together with credentials is refused at init (and so at boot), - * wherever the `'*'` comes from; `'*'` without credentials still boots. + * wherever the `'*'` comes from; `'*'` without credentials still boots; + * 5. a configured list admits a loopback origin beside it on a development + * boot only — the list itself, and nothing more, everywhere else; + * 6. a pattern whose host part is the bare `*` (`https://*`, `*://*`) admits + * every host, so it is refused with credentials exactly as `'*'` is. + * + * The rule itself is `resolveCorsAdmission` (`cors-admission.ts`), which + * `@objectstack/hono`'s `createHonoApp` reads too; the pin that holds every + * door to the same answer lives in `@objectstack/http-conformance`. * * "Admitted" is read off `Access-Control-Allow-Origin` — the one header a * browser checks before handing a cross-origin response to the page. @@ -164,14 +172,15 @@ describe('3. the default admits a loopback origin on a development boot only', ( }); }); -describe("4. '*' together with credentials is refused at init", () => { - const refusal = async (options: HonoPluginOptions): Promise => { - const plugin = new HonoServerPlugin({ port: 0, serverTiming: false, ...options }); - const outcome = await plugin.init(fakeCtx()).then(() => null, (e: unknown) => e); - expect(outcome).toBeInstanceOf(Error); - return outcome as Error; - }; +/** Run the real `init()` and return what it threw; a resolved init fails the case. */ +async function refusal(options: HonoPluginOptions): Promise { + const plugin = new HonoServerPlugin({ port: 0, serverTiming: false, ...options }); + const outcome = await plugin.init(fakeCtx()).then(() => null, (e: unknown) => e); + expect(outcome).toBeInstanceOf(Error); + return outcome as Error; +} +describe("4. '*' together with credentials is refused at init", () => { it('from the `cors.origins` option, with credentials left at their default', async () => { const err = await refusal({ cors: { origins: '*' } }); // The named subjects: both variables an operator can set to resolve it. @@ -210,3 +219,79 @@ describe("4. '*' together with credentials is refused at init", () => { expect(res.headers.get('access-control-allow-credentials')).toBeNull(); }); }); + +describe('5. a configured list admits a loopback origin beside it on a development boot only', () => { + it('outside development, a loopback origin the list does not name is not admitted', async () => { + const app = await boot({ cors: { origins: [CONFIGURED] } }); + expect(await allowedOnGet(app, CONFIGURED)).toBe(CONFIGURED); + expect(await allowedOnGet(app, LOOPBACK)).toBeNull(); + expect(await allowedOnPreflight(app, LOOPBACK)).toBeNull(); + expect(await allowedOnGet(app, 'http://127.0.0.1:3001')).toBeNull(); + expect(await allowedOnGet(app, 'http://[::1]:3000')).toBeNull(); + }); + + it('the same for a list read from OS_CORS_ORIGIN, patterns included, and for an unset or `test` NODE_ENV', async () => { + process.env.OS_CORS_ORIGIN = `${CONFIGURED},https://*.example.net`; + for (const value of ['production', undefined, 'test']) { + if (value === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = value; + const app = await boot(); + expect(await allowedOnGet(app, 'https://tenant.example.net')).toBe('https://tenant.example.net'); + expect(await allowedOnGet(app, LOOPBACK)).toBeNull(); + } + }); + + it('on a development boot, a loopback origin is admitted beside the list, and still no foreign origin', async () => { + process.env.NODE_ENV = 'development'; + const app = await boot({ cors: { origins: [CONFIGURED] } }); + expect(await allowedOnGet(app, CONFIGURED)).toBe(CONFIGURED); + expect(await allowedOnGet(app, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnPreflight(app, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + }); + + it('a list that names a loopback pattern admits it in every mode, and only what it names', async () => { + const app = await boot({ cors: { origins: 'http://localhost:*' } }); + expect(await allowedOnGet(app, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnGet(app, 'http://127.0.0.1:3001')).toBeNull(); + }); +}); + +describe("6. a pattern whose host part is the bare `*` is refused with credentials, as '*' is", () => { + const ALL_HOST = ['https://*', 'http://*', '*://*', 'http://*:*', 'https://*:8443']; + + it('from the `cors.origins` option, alone or inside a list, naming the entry', async () => { + for (const entry of ALL_HOST) { + const alone = await refusal({ cors: { origins: entry } }); + expect(alone.message).toContain(`'${entry}'`); + expect(alone.message).toContain('OS_CORS_ORIGIN'); + expect(alone.message).toContain('OS_CORS_CREDENTIALS'); + await refusal({ cors: { origins: [CONFIGURED, entry] } }); + } + }); + + it('from OS_CORS_ORIGIN, alone or inside a list', async () => { + for (const entry of ALL_HOST) { + process.env.OS_CORS_ORIGIN = entry; + await refusal({}); + process.env.OS_CORS_ORIGIN = `${CONFIGURED}, ${entry}`; + const err = await refusal({}); + expect(err.message).toContain(`'${entry}'`); + } + }); + + it('boots without credentials, admitting what the pattern matches with no credentials header', async () => { + process.env.OS_CORS_ORIGIN = 'https://*'; + process.env.OS_CORS_CREDENTIALS = 'false'; + const app = await boot(); + const res = await app.request('/probe', { headers: { Origin: FOREIGN } }); + expect(res.headers.get('access-control-allow-origin')).toBe(FOREIGN); + expect(res.headers.get('access-control-allow-credentials')).toBeNull(); + }); + + it('a pattern that names part of the host is not one: it boots with credentials', async () => { + const app = await boot({ cors: { origins: ['https://*.example.com', 'http://localhost:*'] } }); + expect(await allowedOnGet(app, 'https://tenant.example.com')).toBe('https://tenant.example.com'); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index c4787e056ac..26f623b2f5e 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, isDevelopmentBoot } from '@objectstack/core'; +import { Plugin, PluginContext } from '@objectstack/core'; import { RestServerConfig } from '@objectstack/spec/api'; import { registerCurrentUserEndpoints } from './current-user-endpoints'; import { @@ -13,7 +13,7 @@ import { cors } from 'hono/cors'; import { serveStatic } from '@hono/node-server/serve-static'; import * as fs from 'fs'; import * as path from 'path'; -import { createOriginMatcher, isLocalhostOrigin, normalizeOriginPatterns } from './pattern-matcher'; +import { resolveCorsAdmission } from './cors-admission'; import type { HttpsListenerMaterial } from './adapter'; import { readEnvWithDeprecation } from '@objectstack/types'; import { @@ -76,13 +76,16 @@ export interface HonoPluginOptions { * Mounted by default. It admits the origins it is told about — * {@link HonoCorsOptions.origins}, else `OS_CORS_ORIGIN` — and, with * neither set, **no cross-origin caller**: a same-origin request needs no - * CORS answer, and a loopback origin (`localhost` / `127.0.0.1` / `[::1]`, - * any port) is admitted on a development boot only (`NODE_ENV` exactly - * `development`, which `os dev` and `os serve --dev` set). + * CORS answer. A loopback origin (`localhost` / `127.0.0.1` / `[::1]`, any + * port) is admitted, with a list or without one, on a development boot + * only (`NODE_ENV` exactly `development`, which `os dev` and + * `os serve --dev` set). * - * `'*'` together with credentials (on unless turned off) is refused at - * init, so the boot fails: name the origins instead, or turn credentials - * off to serve `'*'` to anonymous callers. + * `'*'`, or a pattern whose host part is the bare `*` (`https://*`), + * together with credentials (on unless turned off) is refused at init, so + * the boot fails: name the origins instead, or turn credentials off to + * serve it to anonymous callers. The rule is `resolveCorsAdmission`, which + * `@objectstack/hono`'s `createHonoApp` reads too. * * Can also be controlled via environment variables: * OS_CORS_ENABLED, OS_CORS_ORIGIN, OS_CORS_CREDENTIALS, OS_CORS_MAX_AGE @@ -370,71 +373,30 @@ export class HonoServerPlugin implements Plugin { // ─── CORS Middleware ────────────────────────────────────────────────── // Mounted by default. Controlled via options.cors or environment variables. // - // What it ADMITS is decided here, and the governing text is - // `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an origin - // list that is explicit. So the answer follows what was configured, and - // nothing else: - // - // - a list (`cors.origins`, else `OS_CORS_ORIGIN`) — the pattern matcher - // admits exactly that list (its own loopback convenience included); - // - no list — no cross-origin caller. Nothing here needs the host's own - // origin: a browser applies CORS to cross-origin requests only, so the - // Console and every page this server serves are untouched. A loopback - // origin is admitted on a development boot only — `isDevelopmentBoot`, - // the same reading the auth secret's development fallback uses; the - // transport has no `--dev` flag of its own, and `os serve --dev` sets - // `NODE_ENV=development` before any plugin initialises; - // - `'*'` in the list while credentials are on — refused, here, so the - // boot fails (`kernel.bootstrap()` rethrows an `init()` throw, the - // route `@objectstack/plugin-dev`'s production guard takes). It used - // to be turned into "reflect whatever origin asked", silently. + // What it ADMITS is not decided here: `resolveCorsAdmission` + // (`cors-admission.ts`) is the one rule, and `@objectstack/hono`'s + // `createHonoApp` reads it too, so one configuration gets one answer at + // both doors. The governing text is `docs/HARDENING.md` § CORS: a list + // (`cors.origins`, else `OS_CORS_ORIGIN`) admits exactly that list; no + // list admits no cross-origin caller; a loopback origin is admitted on + // a development boot only; and an entry that admits every host (`'*'`, + // `https://*`) while credentials are on is refused — here, in `init()`, + // so the boot fails (`kernel.bootstrap()` rethrows an `init()` throw, + // the route `@objectstack/plugin-dev`'s production guard takes). const corsDisabledByEnv = readEnvWithDeprecation('OS_CORS_ENABLED', 'CORS_ENABLED', { silent: true }) === 'false'; if (this.options.cors !== false && !corsDisabledByEnv) { const corsOpts = typeof this.options.cors === 'object' ? this.options.cors : {}; const enabled = corsOpts.enabled ?? true; if (enabled) { - // `undefined` = nobody named a list: the default described above. - let configuredOrigin: string | string[] | undefined; - const corsOriginEnv = readEnvWithDeprecation('OS_CORS_ORIGIN', 'CORS_ORIGIN', { silent: true }); - if (corsOpts.origins) { - configuredOrigin = corsOpts.origins; - } else if (corsOriginEnv) { - const envOrigin = corsOriginEnv.trim(); - configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin; - } - - const credentials = corsOpts.credentials ?? (readEnvWithDeprecation('OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', { silent: true }) !== 'false'); + const admission = resolveCorsAdmission({ + door: { name: '@objectstack/plugin-hono-server', originsOption: 'cors.origins' }, + origins: corsOpts.origins, + credentials: corsOpts.credentials, + }); const maxAgeEnv = readEnvWithDeprecation('OS_CORS_MAX_AGE', 'CORS_MAX_AGE', { silent: true }); const maxAge = corsOpts.maxAge ?? (maxAgeEnv ? parseInt(maxAgeEnv, 10) : 86400); - // Always a function, so `hono/cors` never sees a bare `'*'` and - // every answer passes through one of the two branches below. - let origin: (requestOrigin: string) => string | null; - let admits: string | string[]; - - if (configuredOrigin === undefined) { - const developmentBoot = isDevelopmentBoot(undefined); - origin = (requestOrigin: string) => - developmentBoot && isLocalhostOrigin(requestOrigin) ? requestOrigin : null; - admits = developmentBoot ? 'loopback origins only (development boot)' : 'no cross-origin caller'; - } else { - if (credentials && normalizeOriginPatterns(configuredOrigin).includes('*')) { - throw new Error( - "@objectstack/plugin-hono-server refuses CORS origin '*' with credentials: together " - + 'they admit every origin for credentialed cross-origin requests. Name the origins ' - + 'that may call with credentials (OS_CORS_ORIGIN=https://app.example.com, ' - + 'comma-separated, or the `cors.origins` option), or set OS_CORS_CREDENTIALS=false ' - + "(or `cors.credentials: false`) to serve '*' without credentials.", - ); - } - // Exact origins and wildcard patterns ("https://*.example.com", - // "http://localhost:*") alike — one matcher, the one the - // serverless preflight short-circuit shares. - origin = createOriginMatcher(configuredOrigin); - admits = configuredOrigin; - } - const rawApp = this.server.getRawApp(); // Both defaults come from `adapter.ts` (#3786). They used to be // spelled out here AND in the `@objectstack/hono` adapter, each @@ -447,15 +409,15 @@ export class HonoServerPlugin implements Plugin { ])); rawApp.use('*', cors({ - origin, + origin: admission.origin, allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], allowHeaders, exposeHeaders, - credentials, + credentials: admission.credentials, maxAge, })); - ctx.logger.debug('CORS middleware enabled', { origin: admits, credentials }); + ctx.logger.debug('CORS middleware enabled', { origin: admission.admits, credentials: admission.credentials }); } } diff --git a/packages/plugins/plugin-hono-server/src/index.ts b/packages/plugins/plugin-hono-server/src/index.ts index 3909807a847..dafc8d2a414 100644 --- a/packages/plugins/plugin-hono-server/src/index.ts +++ b/packages/plugins/plugin-hono-server/src/index.ts @@ -4,5 +4,6 @@ export * from './hono-plugin'; export * from './current-user-endpoints'; export * from './adapter'; export * from './pattern-matcher'; +export * from './cors-admission'; export * from './route-pattern'; diff --git a/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts index 1e857d8f5f1..b77111fa473 100644 --- a/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts +++ b/packages/plugins/plugin-hono-server/src/pattern-matcher.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect } from 'vitest'; -import { matchOriginPattern, createOriginMatcher, hasWildcardPattern, normalizeOriginPatterns } from './pattern-matcher'; +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import { + matchOriginPattern, + createOriginMatcher, + hasWildcardPattern, + isAllHostOriginPattern, + normalizeOriginPatterns, +} from './pattern-matcher'; describe('matchOriginPattern', () => { describe('exact matching', () => { @@ -49,6 +55,14 @@ describe('matchOriginPattern', () => { }); }); + describe('a loopback origin', () => { + it('matches only a pattern that names it — the pattern, and nothing else', () => { + expect(matchOriginPattern('http://localhost:3000', 'https://app.example.com')).toBe(false); + expect(matchOriginPattern('http://127.0.0.1:3000', 'http://localhost:*')).toBe(false); + expect(matchOriginPattern('http://[::1]:3000', 'https://*.example.com')).toBe(false); + }); + }); + describe('multiple wildcard patterns', () => { it('should match wildcard in multiple positions', () => { expect(matchOriginPattern('https://app.objectui.org', 'https://*.objectui.*')).toBe(true); @@ -119,22 +133,51 @@ describe('createOriginMatcher', () => { expect(matcher('http://localhost:3000')).toBe('http://localhost:3000'); expect(matcher('http://localhost:8080')).toBe('http://localhost:8080'); - // 127.0.0.1 is also considered localhost and always allowed - expect(matcher('http://127.0.0.1:3000')).toBe('http://127.0.0.1:3000'); + // The pattern names `localhost`; another loopback spelling is not it. + expect(matcher('http://127.0.0.1:3000')).toBe(null); + }); + + it('admits a loopback origin beside a list on a development boot only', () => { + const LOOPBACK = [ + 'http://localhost:3000', 'https://localhost:8443', 'http://localhost', + 'http://127.0.0.1:3000', 'http://[::1]:3000', + ]; + const elsewhere = createOriginMatcher('https://app.example.com', { developmentBoot: false }); + const development = createOriginMatcher('https://app.example.com', { developmentBoot: true }); + for (const origin of LOOPBACK) { + expect(elsewhere(origin)).toBe(null); + expect(development(origin)).toBe(origin); + } + expect(development('https://app.example.com')).toBe('https://app.example.com'); + // Non-loopback is still the list's to admit, on either boot. + expect(development('https://evil.com')).toBe(null); + }); + + it('admits nothing for an empty list, loopback included, on either boot', () => { + expect(createOriginMatcher([], { developmentBoot: true })('http://localhost:3000')).toBe(null); + expect(createOriginMatcher(' ', { developmentBoot: true })('http://localhost:3000')).toBe(null); }); + }); - it('should always allow localhost origins regardless of configured patterns', () => { - // Even with a restrictive pattern, localhost should be allowed - const matcher = createOriginMatcher('https://app.example.com'); + describe('the development boot, when not passed', () => { + let saved: string | undefined; + beforeEach(() => { saved = process.env.NODE_ENV; }); + afterEach(() => { + if (saved === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = saved; + }); - expect(matcher('http://localhost:3000')).toBe('http://localhost:3000'); - expect(matcher('http://localhost:5173')).toBe('http://localhost:5173'); - expect(matcher('https://localhost:8443')).toBe('https://localhost:8443'); - expect(matcher('http://localhost')).toBe('http://localhost'); - expect(matcher('http://127.0.0.1:3000')).toBe('http://127.0.0.1:3000'); - expect(matcher('http://[::1]:3000')).toBe('http://[::1]:3000'); - // Non-localhost should still be rejected - expect(matcher('https://evil.com')).toBe(null); + it('is NODE_ENV exactly `development`, read when the matcher is created', () => { + process.env.NODE_ENV = 'development'; + const created = createOriginMatcher('https://app.example.com'); + process.env.NODE_ENV = 'production'; + expect(created('http://localhost:3000')).toBe('http://localhost:3000'); + + for (const value of ['production', 'test', undefined]) { + if (value === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = value; + expect(createOriginMatcher('https://app.example.com')('http://localhost:3000')).toBe(null); + } }); }); @@ -158,6 +201,30 @@ describe('hasWildcardPattern', () => { }); }); +describe('isAllHostOriginPattern', () => { + it("is true for '*' and for a pattern whose host part is the bare `*`", () => { + for (const pattern of ['*', ' * ', '**', 'https://*', 'http://*', '*://*', 'http://*:*', 'https://*:8443']) { + expect(isAllHostOriginPattern(pattern)).toBe(true); + } + }); + + it('is false for a pattern that names any part of the host, and for an exact origin', () => { + for (const pattern of [ + 'https://*.example.com', 'http://localhost:*', '*.example.com', + 'https://app.example.com', 'http://[::1]:3000', 'https://*.*', + ]) { + expect(isAllHostOriginPattern(pattern)).toBe(false); + } + }); + + it('agrees with the matcher: each pattern it names matches an origin on any host', () => { + for (const pattern of ['*', 'https://*', '*://*', 'http://*:*']) { + const origin = pattern.startsWith('http://') ? 'http://unlisted.example:8080' : 'https://unlisted.example'; + expect(matchOriginPattern(origin, pattern)).toBe(true); + } + }); +}); + describe('normalizeOriginPatterns', () => { it('splits comma-separated strings and trims whitespace', () => { expect(normalizeOriginPatterns('a, b , c')).toEqual(['a', 'b', 'c']); diff --git a/packages/plugins/plugin-hono-server/src/pattern-matcher.ts b/packages/plugins/plugin-hono-server/src/pattern-matcher.ts index ca66d177998..816d2b849fb 100644 --- a/packages/plugins/plugin-hono-server/src/pattern-matcher.ts +++ b/packages/plugins/plugin-hono-server/src/pattern-matcher.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { isDevelopmentBoot } from '@objectstack/core'; + /** * CORS origin pattern matching utilities. * @@ -9,6 +11,9 @@ * - `http://localhost:*` → matches any port * - Comma-separated list of the above * + * What a door ADMITS is decided one level up, in `resolveCorsAdmission` + * (`cors-admission.ts`); these helpers only answer whether an origin matches. + * * These helpers are shared between the Hono plugin's CORS middleware and * consumers that need to apply CORS headers outside the Hono request * pipeline (e.g., the Vercel serverless entrypoint's preflight @@ -34,20 +39,16 @@ export function isLocalhostOrigin(origin: string): boolean { } /** - * Check if an origin matches a pattern with wildcards. - * - * Localhost origins (`http(s)://localhost:`, `127.0.0.1`, `[::1]`) - * are **always allowed** regardless of the pattern — this removes the need to - * enumerate every development port in `CORS_ORIGIN`. + * Check if an origin matches a pattern with wildcards — the pattern, and + * nothing else. A loopback origin matches only a pattern that names it + * (`http://localhost:*`); the development-boot convenience that admits one + * beside any list lives in {@link createOriginMatcher}, where the boot is known. * * @param origin The origin to check (e.g., `https://app.example.com`) * @param pattern The pattern to match against (supports `*` wildcard) * @returns true if origin matches the pattern */ export function matchOriginPattern(origin: string, pattern: string): boolean { - // Always allow localhost for development convenience - if (isLocalhostOrigin(origin)) return true; - if (pattern === '*') return true; if (pattern === origin) return true; @@ -62,6 +63,22 @@ export function matchOriginPattern(origin: string, pattern: string): boolean { return regex.test(origin); } +/** + * A pattern whose HOST part is the bare `*` wildcard: `*` itself, and a + * `scheme://*` pattern with or without a port — `https://*`, `*://*`, + * `http://*:*`, `https://*:8443`. {@link matchOriginPattern} expands each of + * them to match every host, which is what `'*'` does, so a door treats them + * as it treats `'*'`: refused while credentials are on. + * + * Deliberately narrow. A pattern that names any part of the host + * (`https://*.example.com`, `http://localhost:*`) is not one. + */ +const ALL_HOST_PATTERN = /^(?:[^:/]+:\/\/)?\*+(?::(?:\d+|\*))?$/; + +export function isAllHostOriginPattern(pattern: string): boolean { + return ALL_HOST_PATTERN.test(pattern.trim()); +} + /** * Normalize a single string / comma-separated string / array into a * trimmed array of non-empty patterns. @@ -75,6 +92,21 @@ export function normalizeOriginPatterns(patterns: string | string[]): string[] { : [patterns.trim()].filter(Boolean); } +export interface OriginMatcherOptions { + /** + * Whether this is a development boot. On one, a loopback origin + * (`localhost`, `127.0.0.1`, `[::1]`, any port) is admitted beside a + * non-empty list; on any other boot only what the list names is. + * + * Omitted, it is read once, when the matcher is created, from + * `isDevelopmentBoot` (`@objectstack/core`) — the reading the CORS doors + * use — so a consumer that applies these answers outside the Hono + * pipeline (the serverless preflight short-circuit) gives the same + * answer as the door on the same boot without passing anything. + */ + developmentBoot?: boolean; +} + /** * Create a CORS origin matcher function that supports wildcard patterns. * @@ -86,12 +118,17 @@ export function normalizeOriginPatterns(patterns: string | string[]): string[] { * @param patterns Single pattern, array of patterns, or comma-separated patterns */ export function createOriginMatcher( - patterns: string | string[] + patterns: string | string[], + options: OriginMatcherOptions = {}, ): (origin: string) => string | null { const patternList = normalizeOriginPatterns(patterns); + // An empty list admits nothing, loopback included, as it always has. + const admitLoopback = patternList.length > 0 + && (options.developmentBoot ?? isDevelopmentBoot(undefined)); return (requestOrigin: string) => { if (!requestOrigin) return null; + if (admitLoopback && isLocalhostOrigin(requestOrigin)) return requestOrigin; for (const pattern of patternList) { if (matchOriginPattern(requestOrigin, pattern)) { return requestOrigin; diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index ed4ab5f254f..a9deae8cdfa 100644 --- a/packages/qa/http-conformance/package.json +++ b/packages/qa/http-conformance/package.json @@ -19,6 +19,7 @@ "@objectstack/hono": "workspace:*", "@objectstack/objectql": "workspace:*", "@objectstack/plugin-approvals": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-webhooks": "workspace:*", "@objectstack/runtime": "workspace:*", diff --git a/packages/qa/http-conformance/src/cors-doors.conformance.test.ts b/packages/qa/http-conformance/src/cors-doors.conformance.test.ts new file mode 100644 index 00000000000..1a806498bd3 --- /dev/null +++ b/packages/qa/http-conformance/src/cors-doors.conformance.test.ts @@ -0,0 +1,246 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Every door in this repository that mounts CORS or reads `OS_CORS_ORIGIN`, + * held to ONE answer for one configuration. + * + * The governing text is `docs/HARDENING.md` § CORS: "Do **not** use + * `origin: '*'` with `credentials: true`", and the go-live checklist's "CORS + * origin list is explicit, not `*`". A door that keeps an older default is a + * hole in that rule however well the other doors keep it, so this file lists + * the doors and asks each of them the same questions. + * + * ## The doors + * + * Measured with a repository grep for every non-test reader of + * `OS_CORS_ORIGIN` and every importer of `hono/cors` under `packages/`: + * + * - `HonoServerPlugin` (`@objectstack/plugin-hono-server`) — the server + * `os serve`, `os dev` and `os start` mount; + * - `createHonoApp` (`@objectstack/hono`) — the app a host composes itself; + * - `AuthManager`'s trusted-origin merge (`@objectstack/plugin-auth`) — + * better-auth trusts the origins `OS_CORS_ORIGIN` lists. + * + * The two transport doors read ONE rule (`resolveCorsAdmission`), so for + * them the answer is the same by construction and this file proves it stays + * so. The auth door answers a narrower question — which listed entries it + * trusts — and is held to the same judgement of each entry: an entry that + * admits every host (`'*'`, or a pattern whose host part is the bare `*`) is + * refused with credentials at the transport and never trusted by auth. + * + * ⛔ A NEW door — another adapter that mounts `hono/cors`, another reader of + * `OS_CORS_ORIGIN` — is added to `DOORS` below in the PR that creates it, and + * must answer every row the way the doors already here do. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import { HonoServerPlugin, normalizeOriginPatterns } from '@objectstack/plugin-hono-server'; +import { createHonoApp } from '@objectstack/hono'; +import { AuthManager } from '@objectstack/plugin-auth'; +import type { AuthManagerOptions } from '@objectstack/plugin-auth'; + +const CONFIGURED = 'https://app.example.com'; +const FOREIGN = 'https://other.example'; +const LOOPBACK = 'http://localhost:5173'; +const SUBDOMAIN = 'https://tenant.example.net'; + +/** The request origins every transport door is asked about, on every row. */ +const PROBES = [CONFIGURED, FOREIGN, LOOPBACK, SUBDOMAIN] as const; + +/** Every variable a door reads, canonical and legacy spellings. */ +const ENV_KEYS = [ + 'OS_CORS_ENABLED', 'CORS_ENABLED', + 'OS_CORS_ORIGIN', 'CORS_ORIGIN', + 'OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', + 'OS_CORS_MAX_AGE', 'CORS_MAX_AGE', + 'OS_SSO_ENABLED', + 'NODE_ENV', +] as const; + +/** + * A transport door's answer: refused when it is mounted, or which probe + * origins it echoes in `Access-Control-Allow-Origin` and whether it sends + * `Access-Control-Allow-Credentials`. + */ +type TransportAnswer = 'refused' | { readonly admits: readonly string[]; readonly credentials: boolean }; + +interface ProbeTarget { + request(path: string, init?: RequestInit): Response | Promise; +} + +async function observe(target: ProbeTarget): Promise { + const admits: string[] = []; + let credentials = false; + for (const origin of PROBES) { + const res = await target.request('/probe', { headers: { Origin: origin, Cookie: 'session=1' } }); + expect(res.status).toBe(200); + if (res.headers.get('access-control-allow-origin') === origin) admits.push(origin); + if (res.headers.get('access-control-allow-credentials') === 'true') credentials = true; + } + return { admits, credentials }; +} + +function fakeCtx(): PluginContext { + const services = new Map(); + return { + logger: { debug() {}, info() {}, warn() {}, error() {} }, + registerService: (name: string, svc: unknown) => services.set(name, svc), + getService: (name: string) => services.get(name), + hook: () => {}, + getKernel: () => ({}), + } as unknown as PluginContext; +} + +interface RawApp extends ProbeTarget { + get(path: string, handler: (c: { text(body: string): Response }) => Response): unknown; +} + +const DOORS = { + transport: { + /** The server `os serve` / `os dev` / `os start` mount — its real `init()`. */ + HonoServerPlugin: async (): Promise => { + const plugin = new HonoServerPlugin({ port: 0, serverTiming: false }); + const refused = await plugin.init(fakeCtx()).then(() => false, () => true); + if (refused) return 'refused'; + const rawApp = (plugin as unknown as { server: { getRawApp(): RawApp } }).server.getRawApp(); + rawApp.get('/probe', (c) => c.text('ok')); + return observe(rawApp); + }, + /** The app a host composes itself — the real `createHonoApp`. */ + createHonoApp: async (): Promise => { + let app: ReturnType; + try { + app = createHonoApp({ kernel: new LiteKernel() as never, prefix: '/api/v1' }); + } catch { + return 'refused'; + } + app.get('/probe', (c) => c.text('ok')); + return observe(app); + }, + }, + /** better-auth's trusted origins, read off a REAL instance: the entries taken from `OS_CORS_ORIGIN`. */ + trust: async (): Promise => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ secret: 'x'.repeat(40), baseUrl: CONFIGURED } as unknown as AuthManagerOptions); + const trusted = ((await manager.getAuthInstance()).options.trustedOrigins ?? []) as string[]; + const listed = normalizeOriginPatterns(process.env.OS_CORS_ORIGIN ?? ''); + return trusted.filter((origin) => listed.includes(origin)); + } finally { + warn.mockRestore(); + } + }, +}; + +interface Row { + readonly name: string; + readonly env: Partial>; + /** What BOTH transport doors answer. */ + readonly transport: TransportAnswer; + /** The `OS_CORS_ORIGIN` entries auth trusts. */ + readonly trusted: readonly string[]; +} + +/** The entries the rows below use that admit every host — never trusted by auth at any row. */ +const ADMITS_EVERY_HOST = ['*', 'https://*'] as const; + +const PRODUCTION = { NODE_ENV: 'production' } as const; +const DEVELOPMENT = { NODE_ENV: 'development' } as const; + +const ROWS: readonly Row[] = [ + { + name: 'no list, not a development boot', + env: { ...PRODUCTION }, + transport: { admits: [], credentials: true }, + trusted: [], + }, + { + name: 'no list, development boot', + env: { ...DEVELOPMENT }, + transport: { admits: [LOOPBACK], credentials: true }, + trusted: [], + }, + { + name: 'an exact origin, not a development boot', + env: { ...PRODUCTION, OS_CORS_ORIGIN: CONFIGURED }, + transport: { admits: [CONFIGURED], credentials: true }, + trusted: [CONFIGURED], + }, + { + name: 'an exact origin, development boot', + env: { ...DEVELOPMENT, OS_CORS_ORIGIN: CONFIGURED }, + transport: { admits: [CONFIGURED, LOOPBACK], credentials: true }, + trusted: [CONFIGURED], + }, + { + name: 'a pattern that names part of the host', + env: { ...PRODUCTION, OS_CORS_ORIGIN: 'https://*.example.net' }, + transport: { admits: [SUBDOMAIN], credentials: true }, + trusted: ['https://*.example.net'], + }, + { + name: "'*' among real origins, credentials on", + env: { ...PRODUCTION, OS_CORS_ORIGIN: `${CONFIGURED},*` }, + transport: 'refused', + trusted: [CONFIGURED], + }, + { + name: 'a pattern whose host part is the bare `*`, credentials on', + env: { ...PRODUCTION, OS_CORS_ORIGIN: `${CONFIGURED}, https://*` }, + transport: 'refused', + trusted: [CONFIGURED], + }, + { + name: "'*' with credentials off", + env: { ...PRODUCTION, OS_CORS_ORIGIN: '*', OS_CORS_CREDENTIALS: 'false' }, + transport: { admits: [...PROBES], credentials: false }, + trusted: [], + }, + { + name: 'a pattern whose host part is the bare `*`, among real origins, credentials off', + env: { ...PRODUCTION, OS_CORS_ORIGIN: `${CONFIGURED},https://*`, OS_CORS_CREDENTIALS: 'false' }, + transport: { admits: [CONFIGURED, FOREIGN, SUBDOMAIN], credentials: false }, + trusted: [CONFIGURED], + }, +]; + +let saved: Record = {}; + +beforeEach(() => { + saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + for (const k of ENV_KEYS) delete process.env[k]; +}); + +afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } +}); + +describe('every CORS door gives one configuration one answer', () => { + for (const row of ROWS) { + describe(row.name, () => { + beforeEach(() => { + for (const [k, v] of Object.entries(row.env)) process.env[k] = v; + }); + + it('both transport doors answer it, and answer it the same', async () => { + const answers = Object.fromEntries( + await Promise.all( + Object.entries(DOORS.transport).map(async ([door, ask]) => [door, await ask()] as const), + ), + ); + expect(answers).toEqual({ HonoServerPlugin: row.transport, createHonoApp: row.transport }); + }); + + it("auth trusts the listed entries the transport honours, and no entry that admits every host", async () => { + const trusted = await DOORS.trust(); + expect(trusted).toEqual(row.trusted); + for (const entry of ADMITS_EVERY_HOST) expect(trusted).not.toContain(entry); + }); + }); + } +}); diff --git a/packages/qa/http-conformance/vitest.config.ts b/packages/qa/http-conformance/vitest.config.ts index cae43f1faa2..fa281bdc519 100644 --- a/packages/qa/http-conformance/vitest.config.ts +++ b/packages/qa/http-conformance/vitest.config.ts @@ -47,6 +47,13 @@ export default defineConfig({ find: /^@objectstack\/service-messaging$/, replacement: path.resolve(__dirname, '../../services/service-messaging/src/index.ts'), }, + // `cors-doors.conformance.test.ts` reads `AuthManager`'s trusted-origin + // merge off a REAL better-auth instance — one of the CORS doors it holds + // to one answer — so that verdict is about this checkout's auth source. + { + find: /^@objectstack\/plugin-auth$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-auth/src/index.ts'), + }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be40adef2f0..485645f1bb0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2233,6 +2233,9 @@ importers: '@objectstack/plugin-approvals': specifier: workspace:* version: link:../../plugins/plugin-approvals + '@objectstack/plugin-auth': + specifier: workspace:* + version: link:../../plugins/plugin-auth '@objectstack/plugin-hono-server': specifier: workspace:* version: link:../../plugins/plugin-hono-server