diff --git a/.changeset/24738-cors-default-no-cross-origin-caller.md b/.changeset/24738-cors-default-no-cross-origin-caller.md new file mode 100644 index 00000000000..96203fb6f93 --- /dev/null +++ b/.changeset/24738-cors-default-no-cross-origin-caller.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-hono-server': minor +--- + +fix(plugin-hono-server)!: the CORS default admits no cross-origin caller, and `'*'` with credentials is refused at boot + +Clause-②: no (narrowing) + + + +**BREAKING** — an admission narrowing of the transport's CORS answer, 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. + +- **Before.** With no origin list configured (no `cors.origins` option and no `OS_CORS_ORIGIN`), the CORS middleware admitted every origin for credentialed cross-origin requests. A configured `'*'` with credentials on was accepted and answered the same way. +- **Now.** With no list, no cross-origin caller is admitted. 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. A list that contains `'*'` while credentials are on is refused in `init()`, so the boot fails, and the message names both remedies. +- **Unchanged.** Same-origin requests need no CORS answer, so the Console under `/_console` and every page the server itself serves work as before. A configured list, exact origins or wildcard patterns, is admitted exactly as before, credentials included. `'*'` with credentials off still boots. `cors: false` and `OS_CORS_ENABLED=false` still disable the middleware. Allowed and exposed headers, methods and max-age are unchanged. + +**What an operator does.** A front-end served from another origin that calls this server is now named: `OS_CORS_ORIGIN=https://app.example.com` (comma-separated for several), or the plugin's `cors.origins` option. A boot that now refuses `'*'` either names its origins or sets `OS_CORS_CREDENTIALS=false` to keep serving `'*'` without credentials. `OS_TRUSTED_ORIGINS` is auth's own list, the origins it accepts for callbacks and CSRF checks, and it admits nothing to CORS. Auth also trusts every origin listed in `OS_CORS_ORIGIN`, so that one 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`. diff --git a/content/docs/deployment/environment-variables.mdx b/content/docs/deployment/environment-variables.mdx index 6a6c5798e16..17b00ddebf9 100644 --- a/content/docs/deployment/environment-variables.mdx +++ b/content/docs/deployment/environment-variables.mdx @@ -36,7 +36,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false > +100) and the banner prints the actual bound port. In **production** > (`os start`) a busy port is a hard error (exit 1) — it never silently > drifts, because a shifted port breaks reverse-proxy upstreams, `OS_AUTH_URL` -> callbacks, and `OS_TRUSTED_ORIGINS` (CORS). Pin the port explicitly in +> callbacks, and `OS_TRUSTED_ORIGINS` (auth CSRF). Pin the port explicitly in > production (`OS_PORT=8080 os start`) and keep `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` > in sync with it. @@ -123,8 +123,8 @@ Auth settings precedence: | Variable | Type | Default | Description | |:---|:---|:---|:---| | `OS_CORS_ENABLED` | boolean | `true` | Enable CORS middleware. | -| `OS_CORS_ORIGIN` | csv | `*` | Allowed origins (comma-separated, or `*`). | -| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests. | +| `OS_CORS_ORIGIN` | csv | — | Origins admitted to cross-origin requests: exact origins or patterns such as `https://*.example.com`, comma-separated. Unset, no cross-origin caller is admitted (loopback origins excepted on a development boot); same-origin requests, the Console included, need no entry. `*` is refused at boot unless `OS_CORS_CREDENTIALS=false`. | +| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests from the admitted origins. Must be `false` to serve `OS_CORS_ORIGIN=*`. | | `OS_CORS_MAX_AGE` | number | `86400` | Preflight cache TTL in seconds. | --- diff --git a/content/docs/deployment/production-readiness.mdx b/content/docs/deployment/production-readiness.mdx index 94bf6c5ee83..1494c7ef370 100644 --- a/content/docs/deployment/production-readiness.mdx +++ b/content/docs/deployment/production-readiness.mdx @@ -118,6 +118,11 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma response `X-Request-Id` header. - [ ] better-auth session TTL / refresh / revoke verified (curl checklist in HARDENING.md). +- [ ] CORS admits exactly the cross-origin front-ends you serve. With no + `OS_CORS_ORIGIN` none is admitted (same-origin pages, the Console + included, need no entry); otherwise it lists each origin explicitly, + never `*` — the server refuses `*` at boot while credentials are on. + See [HARDENING.md § CORS](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#cors). - [ ] Cross-tenant negative tests in CI. - [ ] Multi-org deployments: tenant isolation is **active**, not degraded — confirm `features.degradedTenancy` is `false` in `/auth/config`. A diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index 61f911d836b..73a41ee6147 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -411,10 +411,17 @@ and keep three things in sync with the public origin: ```bash OS_AUTH_URL=https://app.example.com # auth callbacks / cookie origin -OS_TRUSTED_ORIGINS=https://app.example.com # CORS allow-list +OS_TRUSTED_ORIGINS=https://app.example.com # auth callback / CSRF allow-list OS_PORT=8080 # must match the proxy upstream ``` +CORS needs no entry here: the Console and the API share the public origin, +and with no `OS_CORS_ORIGIN` the server admits no cross-origin caller. Only a +front-end served from **another** origin needs one, listed explicitly +(`OS_CORS_ORIGIN=https://portal.example.com`, comma-separated for several) — +never `*`, which the server refuses at boot while credentials are on. See +[Environment variables → CORS](/docs/deployment/environment-variables#cors). + ```text title="Caddyfile" app.example.com { reverse_proxy localhost:8080 diff --git a/docs/HARDENING.md b/docs/HARDENING.md index ee7dd9d4b5d..0645357253f 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -192,9 +192,34 @@ curl -H "Authorization: Bearer $TOK" $API/data/account ## CORS -CORS is intentionally **not** opinionated by the runtime — it's an -app-level policy that depends on which origins host your front-end. -Configure it on the Hono adapter: +Which cross-origin callers are admitted is an app-level decision — it +depends on which origins host your front-end — so the server that +`os serve` / `os start` mount (`HonoServerPlugin`) admits only the +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. | + +Same-origin requests need no CORS answer, so the Console under +`/_console` and any page the server itself serves work with no list at +all. A front-end served from **another origin** needs one CORS +variable: + +```bash +OS_CORS_ORIGIN=https://app.example.com +``` + +`OS_TRUSTED_ORIGINS` is auth's own list — the origins it accepts for +its callbacks and CSRF checks — and it admits nothing to CORS. Auth +also trusts every origin listed in `OS_CORS_ORIGIN`, so that one +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: ```ts import { createHonoApp } from '@objectstack/hono'; @@ -209,9 +234,10 @@ const app = createHonoApp({ }); ``` -Do **not** use `origin: '*'` with `credentials: true` — the -combination is rejected by every modern browser anyway, but the -misconfiguration is a common red flag in audits. +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. ## Auditing diff --git a/packages/plugins/plugin-hono-server/README.md b/packages/plugins/plugin-hono-server/README.md index cbabd110be4..67d6acfa8a7 100644 --- a/packages/plugins/plugin-hono-server/README.md +++ b/packages/plugins/plugin-hono-server/README.md @@ -35,6 +35,24 @@ await kernel.bootstrap(); The Hono server plugin supports flexible CORS configuration with wildcard pattern matching. +### Default: no cross-origin caller + +The CORS middleware is mounted by default, and it admits only the origins you name +(`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. + +`'*'` 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: + +```typescript +kernel.use(new HonoServerPlugin({ + cors: { origins: '*', credentials: false } +})); +``` + ### Basic CORS ```typescript @@ -79,19 +97,21 @@ CORS can also be configured via environment variables: ```bash # Single origin -CORS_ORIGIN=https://app.example.com +OS_CORS_ORIGIN=https://app.example.com # Wildcard patterns (comma-separated) -CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai +OS_CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai # Disable CORS -CORS_ENABLED=false +OS_CORS_ENABLED=false # Additional options -CORS_CREDENTIALS=true -CORS_MAX_AGE=86400 +OS_CORS_CREDENTIALS=true # default; must be false for OS_CORS_ORIGIN=* +OS_CORS_MAX_AGE=86400 ``` +The legacy `CORS_*` names are still read. + ### Disable CORS ```typescript diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index a331a0f6ed8..c25de589cdd 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -125,6 +125,20 @@ export interface HttpsListenerMaterial { export interface HonoCorsOptions { enabled?: boolean; + /** + * The origins admitted to cross-origin requests: exact origins and + * wildcard patterns (`https://*.example.com`, `http://localhost:*`), as an + * 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. + * + * `'*'` (alone or in a list) is refused at init while + * {@link HonoCorsOptions.credentials} is on; it is accepted with + * credentials off. + */ origins?: string | string[]; methods?: string[]; /** @@ -144,6 +158,10 @@ export interface HonoCorsOptions { * exposed unless CORS is disabled entirely. */ exposeHeaders?: string[]; + /** + * Send `Access-Control-Allow-Credentials: true` to admitted origins. + * Defaults to `OS_CORS_CREDENTIALS !== 'false'`, i.e. on. + */ credentials?: boolean; maxAge?: number; } 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 new file mode 100644 index 00000000000..6f9aef00d1f --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/cors-default-admission.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * What the transport's CORS answer ADMITS — driven through the real adapter and + * 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 `*`". Four pins, one per input class whose + * answer the plugin decides: + * + * 1. no origin list configured (neither `cors.origins` nor `OS_CORS_ORIGIN`): + * no cross-origin caller is admitted. A same-origin request needs no CORS + * answer at all, so nothing here has to know the host's own origin; + * 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. + * + * "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 { LiteKernel, type Plugin, type PluginContext } from '@objectstack/core'; + +import { HonoServerPlugin, type HonoPluginOptions } from './hono-plugin'; + +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]; + } +}); + +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 { + get(path: string, handler: (c: { text(body: string): Response }) => Response): unknown; + request(path: string, init?: RequestInit): Promise; +} + +/** Run the real `init()`, then mount one probe route behind its middleware. */ +async function boot(options: HonoPluginOptions = {}): Promise { + const plugin = new HonoServerPlugin({ port: 0, serverTiming: false, ...options }); + await plugin.init(fakeCtx()); + const rawApp = (plugin as unknown as { server: { getRawApp(): RawApp } }).server.getRawApp(); + rawApp.get('/probe', (c) => c.text('ok')); + return rawApp; +} + +/** The `Access-Control-Allow-Origin` a credentialed GET from `origin` is answered with. */ +async function allowedOnGet(app: RawApp, origin: string): Promise { + const res = await app.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(app: RawApp, origin: string): Promise { + const res = await app.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'); +} + +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 app = await boot(); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + expect(await allowedOnPreflight(app, FOREIGN)).toBeNull(); + }); + + it('does not admit a loopback origin either, outside a development boot', async () => { + const app = await boot(); + expect(await allowedOnGet(app, LOOPBACK)).toBeNull(); + expect(await allowedOnPreflight(app, LOOPBACK)).toBeNull(); + }); + + it('still answers the request itself: the CORS answer withholds admission, never the response', async () => { + const app = await boot(); + const res = await app.request('/probe', { headers: { Origin: FOREIGN } }); + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); +}); + +describe('2. a configured origin is admitted with credentials, and nothing else is', () => { + it('via the `cors.origins` option', async () => { + const app = await boot({ cors: { origins: [CONFIGURED] } }); + const res = await app.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 allowedOnPreflight(app, CONFIGURED)).toBe(CONFIGURED); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + }); + + it('via OS_CORS_ORIGIN, comma-separated', async () => { + process.env.OS_CORS_ORIGIN = `${CONFIGURED}, https://*.example.net`; + const app = await boot(); + expect(await allowedOnGet(app, CONFIGURED)).toBe(CONFIGURED); + expect(await allowedOnGet(app, 'https://tenant.example.net')).toBe('https://tenant.example.net'); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + }); +}); + +describe('3. the default admits a loopback origin on a development boot only', () => { + it('admits a loopback origin when NODE_ENV is development, and still no foreign origin', async () => { + process.env.NODE_ENV = 'development'; + const app = await boot(); + expect(await allowedOnGet(app, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnGet(app, 'http://127.0.0.1:3001')).toBe('http://127.0.0.1:3001'); + expect(await allowedOnPreflight(app, LOOPBACK)).toBe(LOOPBACK); + expect(await allowedOnGet(app, FOREIGN)).toBeNull(); + expect(await allowedOnPreflight(app, FOREIGN)).toBeNull(); + }); + + it('treats an unset or `test` NODE_ENV as not development', async () => { + for (const value of [undefined, 'test']) { + if (value === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = value; + const app = await boot(); + expect(await allowedOnGet(app, LOOPBACK)).toBeNull(); + } + }); +}); + +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; + }; + + 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. + expect(err.message).toContain('OS_CORS_ORIGIN'); + expect(err.message).toContain('OS_CORS_CREDENTIALS'); + }); + + it('from OS_CORS_ORIGIN, alone or inside a list', async () => { + for (const value of ['*', ` * `, `${CONFIGURED},*`]) { + process.env.OS_CORS_ORIGIN = value; + await refusal({}); + } + }); + + it('with credentials named explicitly, by option or by OS_CORS_CREDENTIALS', async () => { + await refusal({ cors: { origins: ['*'], credentials: true } }); + process.env.OS_CORS_ORIGIN = '*'; + process.env.OS_CORS_CREDENTIALS = 'true'; + await refusal({}); + }); + + it('fails the boot: the kernel rethrows the init refusal', async () => { + const kernel = new LiteKernel({ logger: { level: 'silent' } }); + kernel.use(new HonoServerPlugin({ port: 0, serverTiming: false, cors: { origins: '*' } }) as unknown as Plugin); + const outcome = await kernel.bootstrap().then(() => null, (e: unknown) => e); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toContain('OS_CORS_ORIGIN'); + }); + + it("boots '*' without credentials, admitting any origin with no credentials header", async () => { + process.env.OS_CORS_ORIGIN = '*'; + 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(); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 70d64941790..c4787e056ac 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 } from '@objectstack/core'; +import { Plugin, PluginContext, isDevelopmentBoot } 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, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher'; +import { createOriginMatcher, isLocalhostOrigin, normalizeOriginPatterns } from './pattern-matcher'; import type { HttpsListenerMaterial } from './adapter'; import { readEnvWithDeprecation } from '@objectstack/types'; import { @@ -72,7 +72,18 @@ export interface HonoPluginOptions { /** * CORS configuration. Set to `false` to disable entirely. - * Enabled by default with origin '*'. + * + * 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). + * + * `'*'` 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. + * * Can also be controlled via environment variables: * OS_CORS_ENABLED, OS_CORS_ORIGIN, OS_CORS_CREDENTIALS, OS_CORS_MAX_AGE * (legacy CORS_* names still honoured with a deprecation warning). @@ -357,50 +368,71 @@ export class HonoServerPlugin implements Plugin { } // ─── CORS Middleware ────────────────────────────────────────────────── - // Enabled by default. Controlled via options.cors or environment variables. + // 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. 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) { - let configuredOrigin: string | string[]; + // `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; - } else { - configuredOrigin = '*'; } const credentials = corsOpts.credentials ?? (readEnvWithDeprecation('OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', { silent: true }) !== 'false'); const maxAgeEnv = readEnvWithDeprecation('OS_CORS_MAX_AGE', 'CORS_MAX_AGE', { silent: true }); const maxAge = corsOpts.maxAge ?? (maxAgeEnv ? parseInt(maxAgeEnv, 10) : 86400); - // Determine origin handler based on configuration. - // Always use a function so that localhost origins are - // automatically allowed regardless of the configured - // pattern list (handled inside matchOriginPattern / - // createOriginMatcher). - let origin: string | string[] | ((origin: string) => string | undefined | null); - - // When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin. - // For wildcard patterns (like "https://*.example.com"), always use a matcher function. - // For exact origins, we can pass them directly as string/array. - if (configuredOrigin === '*' && credentials) { - // Credentials mode with '*' - reflect the request origin - origin = (requestOrigin: string) => requestOrigin || '*'; - } else if (hasWildcardPattern(configuredOrigin)) { - // Wildcard patterns (including better-auth style patterns like "https://*.objectui.org") - // Use pattern matcher to support subdomain and port wildcards - origin = createOriginMatcher(configuredOrigin); + // 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 { - // Exact origin(s) — wrap in a function so localhost is - // still auto-allowed via the matcher. - const matcher = createOriginMatcher(configuredOrigin); - origin = (requestOrigin: string) => matcher(requestOrigin); + 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(); @@ -415,7 +447,7 @@ export class HonoServerPlugin implements Plugin { ])); rawApp.use('*', cors({ - origin: origin as any, + origin, allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'], allowHeaders, exposeHeaders, @@ -423,7 +455,7 @@ export class HonoServerPlugin implements Plugin { maxAge, })); - ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials }); + ctx.logger.debug('CORS middleware enabled', { origin: admits, credentials }); } } diff --git a/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts b/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts index 547c4302d30..b2420779686 100644 --- a/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts @@ -91,7 +91,9 @@ async function bootKernel(opts: { selfHosted: boolean; approvals?: boolean }) { const kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); liveKernels.push(kernel); await kernel.use(new ObjectQLPlugin()); - if (opts.selfHosted) await kernel.use(new HonoServerPlugin({ port: 0 })); + // CORS off at both doors, as on the suite's other parity pins: it is transport + // policy each door configures on its own, and not what these pins compare. + if (opts.selfHosted) await kernel.use(new HonoServerPlugin({ port: 0, cors: false })); await kernel.use(new AutomationStub()); if (opts.approvals !== false) await kernel.use(new ApprovalsServicePlugin()); await kernel.bootstrap(); @@ -139,7 +141,7 @@ async function hostedDoor(opts: { approvals?: boolean } = {}) { let server: unknown; try { server = kernel.getService('http.server'); } catch { server = undefined; } expect(server, 'the hosted shape owns no socket').toBeUndefined(); - const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX }); + const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false }); return { kernel, openWithTokens, diff --git a/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts b/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts index 1ac8ad308c0..3d4498227c3 100644 --- a/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts @@ -139,7 +139,9 @@ async function bootKernel(opts: { selfHosted: boolean; webhooks?: boolean; guard const kernel = new LiteKernel({ logger: { level: 'silent' } } as any); liveKernels.push(kernel); const fixture = new FixturePlugin(opts.guardable !== false); - if (opts.selfHosted) kernel.use(new HonoServerPlugin({ port: 0 })); + // CORS off at both doors, as on the suite's other parity pins: it is transport + // policy each door configures on its own, and not what these pins compare. + if (opts.selfHosted) kernel.use(new HonoServerPlugin({ port: 0, cors: false })); kernel.use(fixture); if (opts.webhooks !== false) kernel.use(new WebhookOutboxPlugin()); await kernel.bootstrap(); @@ -190,7 +192,7 @@ async function hostedDoor(opts: { webhooks?: boolean; guardable?: boolean } = {} let server: unknown; try { server = booted.kernel.getService('http.server'); } catch { server = undefined; } expect(server, 'the hosted shape owns no socket').toBeUndefined(); - const app = createHonoApp({ kernel: booted.kernel as any, prefix: PREFIX }); + const app = createHonoApp({ kernel: booted.kernel as any, prefix: PREFIX, cors: false }); return { ...booted, send: (init: RequestInit, path = PATH) => app.request(`http://tenant.example${path}`, init) }; } diff --git a/packages/runtime/src/http-metrics-inbound-coverage.hono.integration.test.ts b/packages/runtime/src/http-metrics-inbound-coverage.hono.integration.test.ts index aed7c422abd..769c148678e 100644 --- a/packages/runtime/src/http-metrics-inbound-coverage.hono.integration.test.ts +++ b/packages/runtime/src/http-metrics-inbound-coverage.hono.integration.test.ts @@ -604,7 +604,10 @@ describe('#9650 §4 — the transport seam that was ruled, and its edges', () => kernel.use( new HonoServerPlugin({ port: 0, - cors: { enabled: true, origins: '*' }, + // The origin the preflight below carries, named: the CORS + // built-in refuses `'*'` while credentials are on, and an + // admitted preflight is the one it answers on the way in. + cors: { enabled: true, origins: ['https://app.test'] }, observability: { metrics }, }), );