Skip to content

Commit 02e6d73

Browse files
fix(cors)!: one CORS admission rule at every door — createHonoApp's default, loopback beside a list, all-host patterns, auth's trusted origins (#24873)
Fixes #24809 Clause-②: yes (narrowing) ## What changes Every door in this repository that mounts CORS or reads `OS_CORS_ORIGIN` now gives one configuration one answer. 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 `*`". #24738's Expected adds: "Localhost stays admitted in dev only, if at all." This PR closes the family #24738 (PR #24806) opened, at the four locations the card and its comment `6288561947` name. - **One rule.** `resolveCorsAdmission` (new, `packages/plugins/plugin-hono-server/src/cors-admission.ts`) decides what a transport door admits from the door's own options and the `OS_CORS_*` environment. `HonoServerPlugin` reads it in `init()`. `@objectstack/hono`'s `createHonoApp` reads it when the app is created. Each door only maps its own option names (`cors.origins` and `cors.origin`) onto it, and nothing is copied. - **Location 1, `createHonoApp`.** Its old default was the combination #24738 removed from `HonoServerPlugin`: with no list, every origin was admitted with credentials, and `'*'` with credentials on was accepted. It now answers exactly as `HonoServerPlugin` does. - **Location 2, loopback beside a list.** `matchOriginPattern` admitted every loopback origin ahead of any pattern, in every mode. It now matches the pattern and nothing else. `createOriginMatcher` admits a loopback origin beside a non-empty list on a development boot only. - **Location 3, all-host patterns.** `isAllHostOriginPattern` (new) names `'*'` and a pattern whose host part is the bare `*`: `https://*`, `*://*`, `http://*:*`, `https://*:8443`. With credentials on, any such entry is refused exactly as `'*'` is, and the message names the entry. - **Location 4, auth's trusted origins.** `auth-manager.ts` tested the whole `OS_CORS_ORIGIN` value against `'*'` and split it afterwards. It now judges each entry, and an entry that admits every host never enters better-auth's trusted origins. Only that block is touched. `OS_TRUSTED_ORIGINS`, the preview and root-domain wildcards, SSO discovery and the development substitution are unchanged. - **The enumeration pin.** `packages/qa/http-conformance/src/cors-doors.conformance.test.ts` lists the three doors and asks each one the same nine configurations. The development boot is `isDevelopmentBoot` from `@objectstack/core` (`NODE_ENV` exactly `development`), the reading PR #24806 uses. The rule reads it once and passes it to the matcher. The matcher takes it as an optional `{ developmentBoot }`. When that option is omitted, the matcher reads the same signal when it is created. That default is deliberate. The cloud's serverless preflight short-circuit imports `createOriginMatcher` with one argument, so it now follows the matcher's new answer and agrees with the transport on the same boot without a change there. An option that defaulted to `false` would have made that preflight refuse loopback on a development boot while the transport admitted it, which is the preflight-versus-request divergence the module header records. The cross-repo consumer is NOT MEASURED from here. ## Contract Every input whose answer changes. "Admitted" means the origin is echoed in `Access-Control-Allow-Origin`. "Development boot" means `NODE_ENV` is exactly `development`. | Door | Input | Before | After | | --- | --- | --- | --- | | `createHonoApp` | No list, credentials on (the default), not a development boot | every origin admitted, with credentials | no cross-origin caller admitted | | `createHonoApp` | No list, credentials on, development boot | every origin admitted, with credentials | loopback origins only, with credentials | | `createHonoApp` | No list, `OS_CORS_CREDENTIALS=false`, not a development boot | every origin admitted, without credentials | no cross-origin caller admitted | | `createHonoApp` | No list, `OS_CORS_CREDENTIALS=false`, development boot | every origin admitted, without credentials | loopback origins only, without credentials | | `createHonoApp` | A list holding `*` (option or `OS_CORS_ORIGIN`, alone or among other entries), credentials on | created; every origin admitted, with credentials | `createHonoApp` throws | | `HonoServerPlugin`, `createHonoApp` | A list holding a pattern whose host part is the bare `*`, credentials on | booted; every host the pattern matches admitted, with credentials | `init()` throws and the boot fails, or `createHonoApp` throws | | `HonoServerPlugin`, `createHonoApp` | A list with at least one pattern, not a development boot | the list, plus every loopback origin | the list only | | `HonoServerPlugin` | A list of exact origins only, not a development boot | the list, plus every loopback origin | the list only | | `createHonoApp` | A list of exact origins only, development boot | the list only | the list, plus loopback origins (a gain, development boot only) | | `createHonoApp` | The `cors.origin` option as one comma-separated string of exact origins (no `*`), credentials on or off | no origin admitted (`OS_CORS_ORIGIN` written the same way was already split, and is unchanged) | each listed origin admitted, and on a development boot loopback origins too, as in the row above (a widening under the `yes` line, only toward what the operator listed, which the option's TSDoc already promised: "Comma-separated string or array"; the one exception to the exact-origin line under "Unchanged") | | `AuthManager` (plugin-auth) | `OS_CORS_ORIGIN` holding `*` among other entries | `*` entered better-auth's trusted origins | `*` is dropped; the other entries are trusted as before | | `AuthManager` | `OS_CORS_ORIGIN` of `*` written with surrounding space | `*` entered the trusted origins | nothing enters from it | | `AuthManager` | `OS_CORS_ORIGIN` holding a pattern whose host part is the bare `*` | the pattern entered the trusted origins | the pattern is dropped | Unchanged: - An exact origin, or a pattern that names part of the host (`https://*.example.com`, `http://localhost:*`), is admitted as before, with or without credentials, at both transport doors, and auth trusts it as before. - On a development boot, `HonoServerPlugin` admits loopback origins beside a list as before. With no list, `HonoServerPlugin` answers as #24738 left it. - `*`, or an all-host pattern, with credentials off still boots and admits without credentials. - An empty list (`cors.origins: []`, or `OS_CORS_ORIGIN` of whitespace) admits nothing, loopback included, in every mode, as before. - `cors: false`, `cors.enabled: false` and `OS_CORS_ENABLED=false` still mount nothing. Allowed and exposed headers, methods and max-age are unchanged. Preflights are still answered `204`, admitted or not. - Same-origin requests are outside CORS, so the Console and every page the server serves are unaffected. `OS_TRUSTED_ORIGINS` admits nothing to CORS (ruling A, `6288093770`). **Public surface added (outside the table).** `@objectstack/plugin-hono-server` gains these exports so that one rule is shared by two published doors, `HonoServerPlugin` and `createHonoApp`. They, and the `createHonoApp` development-boot row above, are why the `Clause-②` line reads `yes`: - `resolveCorsAdmission`, with its types `CorsAdmission`, `CorsAdmissionInput` and `CorsAdmissionDoor`; - `isAllHostOriginPattern`; - `OriginMatcherOptions`, the type of `createOriginMatcher`'s new optional second parameter. The refusal message now names the entry, and `createHonoApp` gains the refusal. Nothing is removed or renamed, and no spec key moves. ## Mechanism (measured) - **Before-state, per location.** The new pins were committed first, as `a9c5f629a` (base `b3fc122f0` plus the test files only), and run against that tree's source. `plugin-hono-server` and `@objectstack/hono` were run from source, and `http-conformance` from a `dist/` built at that commit: - `plugin-hono-server` `src/cors-default-admission.test.ts`: `Tests 5 failed | 15 passed (20)`. In each failure, a loopback origin beside a list was admitted outside development, or an all-host pattern with credentials booted. - `@objectstack/hono` `src/hono-cors-admission.test.ts`: `Tests 7 failed | 3 passed (10)`. A foreign origin was echoed with no list, a loopback origin beside an exact list was not admitted on a development boot, and `createHonoApp` did not throw for `*` or an all-host pattern with credentials. - `plugin-auth` `src/auth-manager.cors-origin-trust.test.ts`: `Tests 3 failed | 1 passed (4)`. In each failure, the trusted origins held `*` or an all-host pattern. - `http-conformance` `src/cors-doors.conformance.test.ts`: `Tests 11 failed | 7 passed (18)`. - After the fix, all four files passed (see Tests). - **What better-auth does with an all-host entry.** The card's mechanism note said this was not verified. It was measured on the installed better-auth `1.7.3`, using `matchesOriginPattern` in `dist/auth/trusted-origins.mjs`. better-auth reads `https://*` and `*://*` each as matching every origin of their scheme, as it reads `*` (and `https://*.*` nearly so). So an all-host pattern in the trusted set has the same effect as `*`, and auth drops the same narrow class the transport refuses. Auth does not depend on `plugin-hono-server`, so its block carries a one-line predicate of the same class. The enumeration pin holds the two equal. No other non-origin entry is judged. - **"Host part is the bare `*`", defined narrowly.** It is the regular expression in `isAllHostOriginPattern`: an optional `scheme://`, then only `*` characters, then an optional `:port` or `:*`. A pattern that names any part of the host is not in the class, including `https://*.*`. No general pattern validator is added. - **Location 2's consumers in this repo.** Two places relied on loopback beside a list outside development, and both were the matcher's own unit tests in `pattern-matcher.test.ts` ("should always allow localhost origins regardless of configured patterns", and `127.0.0.1` beside `http://localhost:*`). They pinned the old convenience, and they now pin the development-only rule. No other test, example or shipped code relies on it: - the only `Access-Control-Allow-Origin` assertions in the repo are `@objectstack/hono`'s `hono.test.ts`, whose loopback case names `http://localhost:*` in its own list, and `plugin-hono-server`'s pin file; - no example configures a CORS list; - objectui's console reaches the API same-origin (its Vite `/api` proxy in dev, and `VITE_SERVER_URL` empty in both `.env` files; read-only, `../objectui` at `cd4623d06`). - **The doors.** A grep of `packages/` for non-test readers of `OS_CORS_ORIGIN` and importers of `hono/cors` returns three doors: `hono-plugin.ts`, `adapters/hono/src/index.ts` and `auth-manager.ts`. No other adapter mounts CORS. ## Tests All runs are on `fb509fc0a` (this branch merged with `origin/main` `9a06f0be0`) unless a line names another commit. `bb2fde296`, the head this PR opens with, differs from it only in the changeset's text. The pins, by what they assert: - `plugin-hono-server` `src/cors-default-admission.test.ts`, describes 5 and 6: - outside development, a list admits what it names and no loopback origin, whether the list comes from the option or from `OS_CORS_ORIGIN`; - on a development boot, a loopback origin is admitted beside the list; - a list that names `http://localhost:*` admits it in every mode, and nothing else; - each all-host pattern, alone or in a list, by option or by env, is refused in `init()`, and the message names the entry and both remedies; - an all-host pattern without credentials boots; - `https://*.example.com` boots with credentials. - `plugin-hono-server` `src/pattern-matcher.test.ts`: - `matchOriginPattern` matches the pattern only; - `createOriginMatcher` admits loopback beside a list on a development boot only, and an empty list admits nothing; - the default development reading is `NODE_ENV` exactly `development`, read at creation; - `isAllHostOriginPattern` names the class and not its neighbours. - `@objectstack/hono` `src/hono-cors-admission.test.ts`: `createHonoApp` answers rows 1 to 3 of the plugin's pin file the same way, with its refusal thrown when the app is created. - `plugin-auth` `src/auth-manager.cors-origin-trust.test.ts`, read off a real better-auth instance: - a `*` entry among real origins, a spaced `*`, and each all-host pattern never enter the trusted origins; - the real origins and a host-naming pattern do. - `http-conformance` `src/cors-doors.conformance.test.ts`, for each of nine configurations: - both transport doors give the same answer (refused, or the same admitted probe origins and credentials flag), and it is the row's answer; - auth trusts exactly the row's listed entries, and never `*` or `https://*`. Suite runs, each command exiting 0: - `pnpm --filter @objectstack/plugin-hono-server test`: 35 files, 448 tests passed. `typecheck` passed. - `pnpm --filter @objectstack/hono test`: 12 files, 225 tests passed. `typecheck` passed. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 171 files, 3264 passed and 10 skipped. `typecheck` passed. - `pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2`: 19 files, 246 tests passed. `typecheck` passed. - Each new test file is listed by its package's `tsc -p tsconfig.test.json --listFiles`. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` derived 85 families for this diff on `fb509fc0a`, and all 85 ran. - 84 exited 0. - `pnpm check:dual-build-cjs-loads` exited 3 with `PREREQUISITE NOT MET`. It reads every package's built output, and this worktree built only the closure it tests. So it is NOT MEASURED locally, and CI's `Lint & Repo Gates` runs it. - `--ran` reconciliation, with every exit code recorded: `85 derived, 84 run, 1 NOT-MEASURED, 0 UNRUN`. - The changeset family was re-run on `bb2fde296`, after the changeset's last edit. Each of these exited 0: - `check-adr-0087-registration.mjs --base origin/main`, with the `no-migration-prescription` exemption noticed; - `check-changeset-no-major.mjs --base origin/main`; - `check-empty-changeset.mjs --base origin/main`; - `check-changeset-fixed.mjs`; - `check:doc-authoring`. - `pnpm lint` on `bb2fde296`: exit 0. - `check-platform-checklist.mjs --changed-from` was not run, because no exported symbol is renamed or removed. - CI check-runs were pending when this was written. ## Changeset `.changeset/24809-cors-doors-one-admission-rule.md` bumps `@objectstack/plugin-hono-server`, `@objectstack/hono` and `@objectstack/plugin-auth` as `minor` (the `fixed` group; on `main` it is at `18.0.0`, versioned in `06cab4849` and tagged, and `.changeset/pre.json` is gone, so no pre-release tag applies). It is marked **BREAKING**: an accept-set narrowing shipped as `minor` under the launch-window convention. It carries the `Clause-②` line and the ADR-0087 disposition `not-required (no-migration-prescription)`, the same form #24738's changeset uses. Its before and after bullets match the Contract table, and it names the development-boot gain at `createHonoApp`. `@objectstack/http-conformance` is private and gains only a test, a devDependency on `@objectstack/plugin-auth` and a source alias for it. `18.0.0` was versioned after this branch's merge base, so the next version pass consumes this changeset, not `18.0.0`; landing waits on the maintainer's word. ## Acceptance notes - **Release.** `18.0.0` is versioned (`06cab4849`, `chore: version packages`) and tagged, after this branch's merge base, so this changeset is consumed by the next version pass. Landing waits on the maintainer's word (the seat's ACCEPT `6291362699`, item 2), which also covers whether `18.0.0` closed the launch window the `minor` grade rests on; until then `minor` with the **BREAKING** banner is the form the gate enforces on `main`. Built as if it lands on `main` now. - **The roster is declared, not discovered.** The enumeration pin lists the three doors measured at the base. A new door is caught only when it is added to `DOORS`. Discovering doors by scanning every package's source would need a cross-package input radius over all of `packages/**/src` (`check:cross-package-test-inputs`), and that would put this suite on every PR. That is a new gate, so it is not added here. - **`content/docs/deployment/environment-variables.mdx`** (`OS_CORS_ORIGIN` row) says "`*` is refused at boot unless `OS_CORS_CREDENTIALS=false`". That is still true, but it does not name the all-host patterns. It is outside this claim's docs face. The `cli.mdx` "(CORS)" line is held off as before. - **A stale comment inside the auth block.** "When CORS allows all origins (default)" above the development substitution predates #24738. That behaviour is held off, so the comment is left as it is. - **The wildcard note in `serve.ts`** (about `:4182`) says better-auth's glob does not cross dot separators. Measured on `1.7.3`, it does: `https://*.example.com` matches `https://a.b.example.com`. This is pre-existing, outside this diff, and a docs precision at most. - **`https://*.*`** matches nearly every public host at both matchers. It is outside the narrow class by design, and is reported here, not refused. - **Cloud.** The preflight short-circuit follows `createOriginMatcher`'s new answer. A cloud deployment that relied on loopback beside its list outside development names those origins. NOT MEASURED from here. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7343229 commit 02e6d73

19 files changed

Lines changed: 1006 additions & 161 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/plugin-hono-server': minor
3+
'@objectstack/hono': minor
4+
'@objectstack/plugin-auth': minor
5+
---
6+
7+
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
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) a narrowing of CORS admission at three doors plus a boot refusal of one more configuration class: no authorable metadata key, spelling, type or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. The remedy is a deployment variable or the door's own origin option, which no ledger entry can derive. The other categories are closed on facts: the packages publish (not unpublished); no ADR-0087 id covers these doors (not already-registered); and the change is an admission verdict, not an interface, a type or an export retirement (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
12+
13+
**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.
14+
15+
- **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`.
16+
- **`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.
17+
- **`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").
18+
- **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.
19+
- **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.
20+
- **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.
21+
- **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.
22+
23+
**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`.

‎docs/HARDENING.md‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ origins you name:
200200
| Configuration | What is admitted |
201201
| --- | --- |
202202
| 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. |
203-
| 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. |
204-
| `*` while credentials are on | **Refused at boot** — the boot fails with a message naming the two remedies. |
203+
| 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. |
204+
| `*`, 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. |
205205

206206
Same-origin requests need no CORS answer, so the Console under
207207
`/_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
219219
through the plugin's `cors.origins` option instead is not seen by
220220
auth, so such a front-end is then also named in `OS_TRUSTED_ORIGINS`.
221221

222-
Composing the Hono adapter yourself, name the origins there as well:
222+
Composing the Hono adapter yourself, `createHonoApp` answers by the
223+
same rule — the table above holds for it too, with its own `cors.origin`
224+
option in place of `cors.origins` — so with no list it admits no
225+
cross-origin caller. Name the origins there:
223226

224227
```ts
225228
import { createHonoApp } from '@objectstack/hono';
@@ -235,9 +238,12 @@ const app = createHonoApp({
235238
```
236239

237240
Do **not** use `origin: '*'` with `credentials: true`: together they
238-
admit every origin for credentialed requests. `HonoServerPlugin`
239-
refuses the combination at boot; `*` is accepted only with credentials
240-
off (`OS_CORS_CREDENTIALS=false`), for an API that takes no cookies.
241+
admit every origin for credentialed requests. Both doors refuse the
242+
combination, and a pattern whose host part is the bare `*` with it —
243+
`HonoServerPlugin` at boot, `createHonoApp` when the app is created;
244+
such an entry is accepted only with credentials off
245+
(`OS_CORS_CREDENTIALS=false`), for an API that takes no cookies, and
246+
auth never trusts it from `OS_CORS_ORIGIN`.
241247

242248
## Auditing
243249

‎packages/adapters/hono/README.md‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ const app = createHonoApp({
3333
export default app; // Cloudflare Workers / Bun / Deno / Vercel Edge
3434
```
3535

36+
### CORS
37+
38+
`createHonoApp` admits by the same rule as `@objectstack/plugin-hono-server`'s
39+
`HonoServerPlugin` (`resolveCorsAdmission`), so one configuration gets one answer at
40+
both: a list admits what it names; no list admits no cross-origin caller; a loopback
41+
origin is admitted, beside a list or without one, on a development boot only
42+
(`NODE_ENV=development`). `'*'`, or a pattern whose host part is the bare `*`
43+
(`https://*`), while credentials are on is refused when the app is created; turn
44+
credentials off to serve it to callers that send no cookies.
45+
3646
### Embed into an existing Hono app
3747

3848
```typescript
@@ -59,8 +69,8 @@ app.use('/api/*', objectStackMiddleware(kernel));
5969
| `kernel` | `ObjectKernel` | — | Bootstrapped kernel. |
6070
| `prefix` | `string` | `'/api'` | Base path. |
6171
| `cors` | `ObjectStackHonoCorsOptions \| false` | Enabled | Set to `false` to disable. |
62-
| `cors.origin` | `string \| string[]` | env `CORS_ORIGIN` or `'*'` | Supports wildcard patterns. |
63-
| `cors.credentials` | `boolean` | `false` | Required for cookie sessions. |
72+
| `cors.origin` | `string \| string[]` | env `OS_CORS_ORIGIN`, else no list | Supports wildcard patterns. With no list, no cross-origin caller is admitted. |
73+
| `cors.credentials` | `boolean` | env `OS_CORS_CREDENTIALS !== 'false'` (on) | Required for cookie sessions. |
6474
| `cors.exposeHeaders` | `string[]` | `['set-auth-token']` merged | Always includes `set-auth-token` for better-auth. |
6575

6676
## Edge runtime notes
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* What `createHonoApp`'s CORS answer ADMITS — through the real `hono/cors`
5+
* middleware (`app.request`), never a captured config.
6+
*
7+
* The governing text is `docs/HARDENING.md` § CORS: "Do **not** use
8+
* `origin: '*'` with `credentials: true`", and the go-live checklist's "CORS
9+
* origin list is explicit, not `*`". This door reads the same rule
10+
* `HonoServerPlugin` does (`resolveCorsAdmission` in
11+
* `@objectstack/plugin-hono-server`, which this package's vitest config
12+
* resolves to SOURCE), so a configuration gets the same answer here as there:
13+
*
14+
* 1. no origin list (neither `cors.origin` nor `OS_CORS_ORIGIN`): no
15+
* cross-origin caller is admitted; a loopback origin is admitted on a
16+
* development boot only;
17+
* 2. a configured list is admitted with credentials, and nothing else is — a
18+
* loopback origin beside it on a development boot only;
19+
* 3. `'*'`, or a pattern whose host part is the bare `*`, together with
20+
* credentials is refused when the app is created; without credentials it
21+
* is served.
22+
*
23+
* "Admitted" is read off `Access-Control-Allow-Origin` — the one header a
24+
* browser checks before handing a cross-origin response to the page.
25+
*/
26+
27+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28+
import type { Hono } from 'hono';
29+
30+
// `@objectstack/runtime` resolves to `src/__mocks__/runtime.ts` (this
31+
// package's vitest alias): the dispatcher is never reached on `/probe`.
32+
import { createHonoApp, type ObjectStackHonoOptions } from './index';
33+
34+
const FOREIGN = 'https://other.example';
35+
const CONFIGURED = 'https://app.example.com';
36+
const LOOPBACK = 'http://localhost:5173';
37+
38+
/** Every variable the CORS block reads, canonical and legacy spellings. */
39+
const CORS_ENV = [
40+
'OS_CORS_ENABLED', 'CORS_ENABLED',
41+
'OS_CORS_ORIGIN', 'CORS_ORIGIN',
42+
'OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS',
43+
'OS_CORS_MAX_AGE', 'CORS_MAX_AGE',
44+
'NODE_ENV',
45+
] as const;
46+
47+
let saved: Record<string, string | undefined> = {};
48+
49+
beforeEach(() => {
50+
saved = {};
51+
for (const name of CORS_ENV) {
52+
saved[name] = process.env[name];
53+
delete process.env[name];
54+
}
55+
// Not a development boot unless a case says so.
56+
process.env.NODE_ENV = 'production';
57+
});
58+
59+
afterEach(() => {
60+
for (const name of CORS_ENV) {
61+
if (saved[name] === undefined) delete process.env[name];
62+
else process.env[name] = saved[name];
63+
}
64+
});
65+
66+
const kernel = { name: 'cors-admission-kernel' } as unknown as ObjectStackHonoOptions['kernel'];
67+
68+
/** The real `createHonoApp`, with one probe route behind its middleware. */
69+
function app(cors?: ObjectStackHonoOptions['cors']): Hono {
70+
const created = createHonoApp({ kernel, prefix: '/api/v1', ...(cors === undefined ? {} : { cors }) });
71+
created.get('/probe', (c) => c.text('ok'));
72+
return created;
73+
}
74+
75+
/** The `Access-Control-Allow-Origin` a credentialed GET from `origin` is answered with. */
76+
async function allowedOnGet(target: Hono, origin: string): Promise<string | null> {
77+
const res = await target.request('/probe', { headers: { Origin: origin, Cookie: 'session=1' } });
78+
expect(res.status).toBe(200);
79+
return res.headers.get('access-control-allow-origin');
80+
}
81+
82+
/** The same, for the preflight a browser sends ahead of a non-simple request. */
83+
async function allowedOnPreflight(target: Hono, origin: string): Promise<string | null> {
84+
const res = await target.request('/probe', {
85+
method: 'OPTIONS',
86+
headers: {
87+
Origin: origin,
88+
'Access-Control-Request-Method': 'POST',
89+
'Access-Control-Request-Headers': 'content-type',
90+
},
91+
});
92+
return res.headers.get('access-control-allow-origin');
93+
}
94+
95+
/** What `createHonoApp` threw; an app that was created fails the case. */
96+
function refusal(cors?: ObjectStackHonoOptions['cors']): Error {
97+
let thrown: unknown = null;
98+
try {
99+
app(cors);
100+
} catch (e) {
101+
thrown = e;
102+
}
103+
expect(thrown).toBeInstanceOf(Error);
104+
return thrown as Error;
105+
}
106+
107+
describe('1. with no origin list configured, no cross-origin caller is admitted', () => {
108+
it('answers a foreign origin with no Access-Control-Allow-Origin, on a request and on a preflight', async () => {
109+
const target = app();
110+
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
111+
expect(await allowedOnPreflight(target, FOREIGN)).toBeNull();
112+
});
113+
114+
it('does not admit a loopback origin either, outside a development boot', async () => {
115+
const target = app();
116+
expect(await allowedOnGet(target, LOOPBACK)).toBeNull();
117+
expect(await allowedOnPreflight(target, LOOPBACK)).toBeNull();
118+
});
119+
120+
it('admits a loopback origin, and still no foreign one, on a development boot', async () => {
121+
process.env.NODE_ENV = 'development';
122+
const target = app();
123+
expect(await allowedOnGet(target, LOOPBACK)).toBe(LOOPBACK);
124+
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
125+
});
126+
});
127+
128+
describe('2. a configured list is admitted with credentials, and nothing else is', () => {
129+
it('via the `cors.origin` option', async () => {
130+
const target = app({ origin: [CONFIGURED] });
131+
const res = await target.request('/probe', { headers: { Origin: CONFIGURED } });
132+
expect(res.headers.get('access-control-allow-origin')).toBe(CONFIGURED);
133+
expect(res.headers.get('access-control-allow-credentials')).toBe('true');
134+
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
135+
});
136+
137+
it('via OS_CORS_ORIGIN, comma-separated, patterns included', async () => {
138+
process.env.OS_CORS_ORIGIN = `${CONFIGURED}, https://*.example.net`;
139+
const target = app();
140+
expect(await allowedOnGet(target, CONFIGURED)).toBe(CONFIGURED);
141+
expect(await allowedOnGet(target, 'https://tenant.example.net')).toBe('https://tenant.example.net');
142+
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
143+
});
144+
145+
it('admits a loopback origin beside the list on a development boot only', async () => {
146+
for (const origin of [[CONFIGURED], 'https://*.example.net']) {
147+
process.env.NODE_ENV = 'production';
148+
expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBeNull();
149+
process.env.NODE_ENV = 'development';
150+
expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBe(LOOPBACK);
151+
}
152+
});
153+
});
154+
155+
describe("3. '*', or a pattern whose host part is the bare `*`, with credentials is refused when the app is created", () => {
156+
it('from the `cors.origin` option, with credentials left at their default, naming the entry', () => {
157+
for (const entry of ['*', 'https://*', '*://*', 'http://*:*']) {
158+
const err = refusal({ origin: entry });
159+
expect(err.message).toContain(`'${entry}'`);
160+
expect(err.message).toContain('OS_CORS_ORIGIN');
161+
expect(err.message).toContain('OS_CORS_CREDENTIALS');
162+
}
163+
});
164+
165+
it('from OS_CORS_ORIGIN, alone or inside a list', () => {
166+
for (const value of ['*', ' * ', `${CONFIGURED},*`, 'https://*', `${CONFIGURED}, *://*`]) {
167+
process.env.OS_CORS_ORIGIN = value;
168+
refusal();
169+
}
170+
});
171+
172+
it('with credentials named explicitly, by option or by OS_CORS_CREDENTIALS', () => {
173+
refusal({ origin: ['*'], credentials: true });
174+
process.env.OS_CORS_ORIGIN = 'https://*';
175+
process.env.OS_CORS_CREDENTIALS = 'true';
176+
refusal();
177+
});
178+
179+
it('serves them without credentials, admitting any origin with no credentials header', async () => {
180+
process.env.OS_CORS_CREDENTIALS = 'false';
181+
for (const value of ['*', 'https://*']) {
182+
process.env.OS_CORS_ORIGIN = value;
183+
const res = await app().request('/probe', { headers: { Origin: FOREIGN } });
184+
expect(res.headers.get('access-control-allow-origin')).toBe(FOREIGN);
185+
expect(res.headers.get('access-control-allow-credentials')).toBeNull();
186+
}
187+
});
188+
});

0 commit comments

Comments
 (0)