From a9c5f629a7d6d07de42222ec849a8cd448d007bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:06:55 +0000 Subject: [PATCH 1/6] test(cors): pin one admission answer at every CORS door, ahead of the fix The pins this card's fix turns green, committed first so each one can be read red against the base's source: - plugin-hono-server: a configured list admits a loopback origin beside it on a development boot only, and a pattern whose host part is the bare `*` is refused with credentials as `'*'` is; - @objectstack/hono: createHonoApp answers the same configurations the same way as HonoServerPlugin; - plugin-auth: OS_CORS_ORIGIN entries reach better-auth's trusted origins one by one, and an entry that admits every host never does; - http-conformance: every door that mounts CORS or reads OS_CORS_ORIGIN is listed and asked the same rows (it gains a plugin-auth devDependency and a source alias for that door). Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../hono/src/hono-cors-admission.test.ts | 188 +++++++++++++ .../auth-manager.cors-origin-trust.test.ts | 85 ++++++ .../src/cors-default-admission.test.ts | 101 ++++++- packages/qa/http-conformance/package.json | 1 + .../src/cors-doors.conformance.test.ts | 246 ++++++++++++++++++ packages/qa/http-conformance/vitest.config.ts | 7 + pnpm-lock.yaml | 3 + 7 files changed, 623 insertions(+), 8 deletions(-) create mode 100644 packages/adapters/hono/src/hono-cors-admission.test.ts create mode 100644 packages/plugins/plugin-auth/src/auth-manager.cors-origin-trust.test.ts create mode 100644 packages/qa/http-conformance/src/cors-doors.conformance.test.ts 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/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-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/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 From 657666ee8e5cff8fa0a9e4807381787a6d3fe550 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:24:10 +0000 Subject: [PATCH 2/6] fix(cors)!: one admission rule for every CORS door; loopback beside a list on a development boot only; an all-host pattern refused with credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/HARDENING.md` § CORS governs: never `'*'` with credentials, and an explicit origin list. - plugin-hono-server: `resolveCorsAdmission` (new, `cors-admission.ts`) is the one rule. `HonoServerPlugin` reads it in `init()`. `createOriginMatcher` admits a loopback origin beside a list on a development boot only (`isDevelopmentBoot`, read when the matcher is created unless passed), and `matchOriginPattern` matches the pattern and nothing else. `isAllHostOriginPattern` (new) names `'*'` and a pattern whose host part is the bare `*`; with credentials on, any such entry is refused, as `'*'` was. - @objectstack/hono: `createHonoApp` reads the same rule, so with no list it admits no cross-origin caller, and it refuses the same configurations when the app is created. - plugin-auth: `OS_CORS_ORIGIN` entries reach better-auth's trusted origins one by one; an entry that admits every host never does. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- packages/adapters/hono/src/index.ts | 77 +++++------- .../plugins/plugin-auth/src/auth-manager.ts | 15 ++- .../plugins/plugin-hono-server/src/adapter.ts | 10 +- .../plugin-hono-server/src/cors-admission.ts | 116 ++++++++++++++++++ .../plugin-hono-server/src/hono-plugin.ts | 96 +++++---------- .../plugins/plugin-hono-server/src/index.ts | 1 + .../src/pattern-matcher.test.ts | 97 ++++++++++++--- .../plugin-hono-server/src/pattern-matcher.ts | 55 +++++++-- 8 files changed, 326 insertions(+), 141 deletions(-) create mode 100644 packages/plugins/plugin-hono-server/src/cors-admission.ts 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.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 83337bf031c..b3937907db6 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -3007,11 +3007,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/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/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; From 649510a309a415009b11393a52f01ba84897dec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:28:54 +0000 Subject: [PATCH 3/6] =?UTF-8?q?docs(cors):=20HARDENING=20=C2=A7=20CORS,=20?= =?UTF-8?q?both=20READMEs=20and=20the=20changeset=20state=20the=20one=20ad?= =?UTF-8?q?mission=20rule=20as=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HARDENING § CORS: the list row says loopback is admitted beside a list on a development boot only; the refusal row and the closing paragraph name the all-host patterns and both doors; the createHonoApp paragraph says the adapter answers by the same rule. - plugin-hono-server README: the same, and where the rule is exported. - @objectstack/hono README: the CORS defaults (no list, credentials on) and the shared rule. - One changeset for the three published packages, minor, breaking, with the Clause-② line and the ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .../24809-cors-doors-one-admission-rule.md | 22 +++++++++++++++++++ docs/HARDENING.md | 18 ++++++++++----- packages/adapters/hono/README.md | 14 ++++++++++-- packages/plugins/plugin-hono-server/README.md | 14 ++++++++---- 4 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 .changeset/24809-cors-doors-one-admission-rule.md 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..be7e31d5365 --- /dev/null +++ b/.changeset/24809-cors-doors-one-admission-rule.md @@ -0,0 +1,22 @@ +--- +'@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-②: no (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 in this release 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. +- **`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. +- **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. `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. `'*'` 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/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 From bb2fde29613a4a06b89a7c69be8b8b405f1fda62 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:00:50 +0000 Subject: [PATCH 4/6] docs(changeset): name the one development-boot gain at createHonoApp An exact-origin list at createHonoApp never admitted a loopback origin; under the shared rule it does on a development boot, as HonoServerPlugin already did. The changeset says so. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24809-cors-doors-one-admission-rule.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/24809-cors-doors-one-admission-rule.md b/.changeset/24809-cors-doors-one-admission-rule.md index be7e31d5365..a5c2798dcac 100644 --- a/.changeset/24809-cors-doors-one-admission-rule.md +++ b/.changeset/24809-cors-doors-one-admission-rule.md @@ -14,7 +14,7 @@ Clause-②: no (narrowing) - **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. - **`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. -- **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. `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. +- **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. `'*'` 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. From df7d1e1fd43c9a6aaa98729fc30ef8bbd3f1b17d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:31:59 +0000 Subject: [PATCH 5/6] =?UTF-8?q?docs(changeset):=20the=20Clause-=E2=91=A1?= =?UTF-8?q?=20line=20reads=20yes=20(narrowing),=20and=20the=20added=20expo?= =?UTF-8?q?rts=20are=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat's answer to the dev report's first open question: the diff adds exports-addressable symbols to @objectstack/plugin-hono-server, so the line reads `yes (narrowing)`. The changeset now names them and why they exist: one rule shared by two published doors. The level stays minor and breaking. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24809-cors-doors-one-admission-rule.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24809-cors-doors-one-admission-rule.md b/.changeset/24809-cors-doors-one-admission-rule.md index a5c2798dcac..2069f253441 100644 --- a/.changeset/24809-cors-doors-one-admission-rule.md +++ b/.changeset/24809-cors-doors-one-admission-rule.md @@ -6,13 +6,13 @@ 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-②: no (narrowing) +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 in this release 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. +- **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. - **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. From 0ffd23e64de690718b939fe22ef8898c4c13f05f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:04:12 +0000 Subject: [PATCH 6/6] docs(changeset): name the HonoServerPlugin default change by the version that shipped it, and add the comma-separated cors.origin row 18.0.0 was versioned on main after this branch's merge base, so "in this release" would read one release off in the CHANGELOG entry that carries this changeset; the sentence now names the change by 18.0.0. createHonoApp's cors.origin written as one comma-separated string of exact origins admitted no origin before and admits each listed origin now. The changeset gains that row, and its "Unchanged" bullet no longer claims the exact-origin answer is unchanged in that one spelling. Prose only. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude --- .changeset/24809-cors-doors-one-admission-rule.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changeset/24809-cors-doors-one-admission-rule.md b/.changeset/24809-cors-doors-one-admission-rule.md index 2069f253441..65f6c434583 100644 --- a/.changeset/24809-cors-doors-one-admission-rule.md +++ b/.changeset/24809-cors-doors-one-admission-rule.md @@ -10,13 +10,14 @@ 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 in this release began. +**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. `'*'` 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. +- **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`.