Skip to content

Commit f7546a4

Browse files
fix(plugin-hono-server)!: the CORS default admits no cross-origin caller, and '*' with credentials is refused at boot (#24806)
Fixes #24738 Clause-②: no (narrowing) ## What changes The CORS middleware that `HonoServerPlugin` (the server `os serve`, `os dev` and `os start` mount) installs by default now admits only the origins it is told about. 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 `*`". - **Old default.** With no origin list configured (no `cors.origins` option, no `OS_CORS_ORIGIN`), every origin was admitted for credentialed cross-origin requests. A configured `'*'` with credentials on was accepted and answered the same way. - **New default.** 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`, read through `isDevelopmentBoot` from `@objectstack/core`, the same reading the auth secret's development fallback uses. - **`'*'` with credentials** is refused in `init()`. `kernel.bootstrap()` rethrows an `init()` throw, so the boot fails, and the message names both remedies (`OS_CORS_ORIGIN`, `OS_CORS_CREDENTIALS=false`). This is the precedent of `@objectstack/plugin-dev`'s production guard (`assertNotProduction`, ADR-0115 D6), which refuses the same way from `init()`. Triage leaned to a loud refusal, and the transport has no non-credentialed fallback that would keep the operator's intent. Docs now state the default as it is: `docs/HARDENING.md` § CORS only, the CORS rows of `environment-variables.mdx`, one CORS step in `self-hosting.mdx` and `production-readiness.mdx`, and the plugin README. Two doc lines called `OS_TRUSTED_ORIGINS` a CORS list. That was never what the transport read, and under the new default it would send an operator to the wrong variable, so both lines are corrected. ## Contract Every input whose answer changes. "Admitted" means the origin is echoed in `Access-Control-Allow-Origin`. | Input | Before | After | | --- | --- | --- | | No list, credentials on (the default), not a development boot | every origin admitted, with credentials | no cross-origin caller admitted | | No list, credentials on, development boot | every origin admitted, with credentials | loopback origins only, with credentials | | No list, `OS_CORS_CREDENTIALS=false`, not a development boot | every origin admitted, without credentials | no cross-origin caller admitted | | No list, `OS_CORS_CREDENTIALS=false`, development boot | every origin admitted, without credentials | loopback origins only, without credentials | | A list holding `*` (option or `OS_CORS_ORIGIN`, alone or among other entries), credentials on | booted; every origin admitted, with credentials | `init()` throws; the boot fails | Unchanged: - A list without `*`, exact origins or patterns, is admitted as before, with or without credentials. It still admits loopback origins alongside it: that is the pattern matcher's existing convenience, and this PR does not touch it. - `*` with credentials off still boots and admits any origin without credentials. - `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 under `/_console` and every page the server itself serves are unaffected. - No export, option, type or spec key moves. `HonoCorsOptions` gains TSDoc only. ## Mechanism (measured) - The before-state was measured on `main`. The new pin file ran against `main`'s source (commit `b1bb0d25c`: `795ff708d` plus the test file only) and gave `Tests 8 failed | 4 passed (12)`. The eight failures were a foreign origin echoed back with credentials on, a loopback origin echoed outside development, and `init()` resolving for `'*'` with credentials. The four that passed are the preservation pins. - No `serve.ts` seam was needed. The host's own origin needs no CORS admission, because a browser applies CORS to cross-origin requests only. The configured list is the one the transport already reads (`cors.origins`, else `OS_CORS_ORIGIN`). The transport reads development-ness through `isDevelopmentBoot`, and `serve` sets `NODE_ENV=development` under `--dev` before any plugin initialises. No second copy of auth's trusted-origin rules was added. - **Consumers in this repo.** Three tests leaned on the old default, and each now names its configuration (tests only; no example and no shipped code depended on it): - `packages/runtime/src/http-metrics-inbound-coverage.hono.integration.test.ts` configured `'*'` with credentials, which the boot now refuses. Measured before its fix: `Plugin init failed: com.objectstack.server.hono` and 1 failed of 27. It now names the origin its preflight carries. - `packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts` and `hono-webhooks-redeliver.conformance.test.ts` are door-parity pins, and they compared the two doors' CORS headers by accident. Both doors now run with `cors: false`, as the suite's other parity pins already do. - Every other test that boots `HonoServerPlugin` with CORS on drives it from a non-browser client (Node's fetch or Hono's `app.request`), and neither enforces CORS. The only assertions on `Access-Control-Allow-Origin` in this repo are `@objectstack/hono`'s own, for its own default (see Acceptance notes). `examples/embed-objectql` mounts no HTTP server. The showcase e2e runs same-origin under `--dev`. objectui's console reaches the API same-origin: through its Vite `/api` proxy in dev, and with `VITE_SERVER_URL` empty in both `.env` files (read-only, `../objectui` at `cd4623d06`). ## Tests All runs below are on `f7cb52d15`, the head this PR opens with, unless a line names another commit. - `pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2 src/cors-default-admission.test.ts`: 12/12 passed. On `main`'s source the same file gave 8 failed and 4 passed, as above. The pins, by what they assert: 1. With no origin list, a foreign origin and (outside development) a loopback origin get no `Access-Control-Allow-Origin`, on a request and on a preflight, and the request itself is still answered. 2. A configured origin, by option or by `OS_CORS_ORIGIN`, is admitted with credentials, and an unlisted one is not. 3. The default admits loopback origins when `NODE_ENV` is `development`, not when it is unset or `test`. 4. `'*'` with credentials is refused in `init()` from every source, and the kernel's bootstrap rethrows it. `'*'` without credentials boots. - `pnpm --filter @objectstack/plugin-hono-server test`: 35 files, 434 tests passed. - `pnpm --filter @objectstack/plugin-hono-server typecheck`: exit 0. The test layer compiles, and `tsc -p tsconfig.test.json --listFiles` lists the new file. - `pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2`: the whole suite, 18 files and 228 tests, passed. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2` on `http-metrics-inbound-coverage.hono.integration.test.ts` and `dispatcher-plugin.rate-limit.integration.test.ts`: 2 files, 35 tests passed. - Consumers of the default through the built `dist/`, all green. They were measured on the tree committed as `8c038d76d`, before the last merge of `main`, which touched none of these paths: - the twelve `@objectstack/runtime` test files that boot `HonoServerPlugin` with CORS on: 123 tests; - the six `@objectstack/client` files that do: 30 tests. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands` derived 98 families for this diff at `f7cb52d15`. All 98 ran, each exit 0. `--ran` reconciliation: `98 derived, 98 run, 0 NOT-MEASURED, 0 UNRUN`. Among them: - `check:adr-0087-registration --base origin/main`: one declared-breaking changeset, `not-required (no-migration-prescription)`; - `check:changeset-no-major`, `check:doc-anchors`, `check:doc-authoring`, `check:doc-security-posture`, `check:nul-bytes`, `check:published-files`; - `check:dual-build-cjs-loads`, `check:type-check-coverage` and `check:type-check-debt`. - `pnpm lint` on `f7cb52d15`: exit 0. - CI check-runs: pending at the time of writing. ## Changeset `.changeset/24738-cors-default-no-cross-origin-caller.md` bumps `@objectstack/plugin-hono-server` as `minor` (the package is in the `fixed` group, pre-release `next`). It is marked **BREAKING**: an accept-set narrowing shipped as `minor` under the launch-window convention, the same form the narrowing changesets already on `main` use (for example `24341-extend-default-row-scope-refused.md`). It carries the `Clause-②` line, the ADR-0087 disposition and what an operator does. `check-changeset-no-major` forbids `major`. Whether this enters 18.0.0 is the maintainer's call (triage), and this PR does not decide it. ## Acceptance notes - **Sibling default, not changed here.** `@objectstack/hono`'s `createHonoApp` (`packages/adapters/hono/src/index.ts`, its CORS block) carries the same default. It sits outside this card's file surface, so it is handed to the seat to file, not widened into this PR. `HARDENING.md` now tells a host composing it to name its origins explicitly. - **Loopback with a configured list.** A configured list admits loopback origins in every mode (`matchOriginPattern`). The card's "localhost in dev only" is scoped to the default, so this is reported, not changed. - **Held-off doc line.** `content/docs/deployment/cli.mdx` (about `:523`) still calls `OS_TRUSTED_ORIGINS` "(CORS)". PR #24681 holds that file, so whichever lands second should correct it. - **Spec default.** `CorsConfigSchema` in spec (`shared/http.zod.ts`) declares `origins` default `'*'` with `credentials` default `false`. The transport does not read it (spec lane, held off). The future `server.cors` admission should carry this default. - **What the refusal covers.** It covers a literal `*` entry. A degenerate pattern such as `https://*` is not refused. - **`HARDENING.md` ordering.** #24740 rewrites other sections of the same file. Triage asks the two to land serially. --- _Generated by [Claude Code](https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd6dab0 commit f7546a4

12 files changed

Lines changed: 394 additions & 50 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-hono-server': minor
3+
---
4+
5+
fix(plugin-hono-server)!: the CORS default admits no cross-origin caller, and `'*'` with credentials is refused at boot
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a narrowing of the transport's CORS admission default plus a boot refusal of one configuration: no authorable metadata key, spelling, export, type or stored shape moves, so `objectstack migrate meta` has nothing to rewrite. The remedy is a deployment variable, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is an admission verdict, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**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.
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
17+
**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`.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
3636
> +100) and the banner prints the actual bound port. In **production**
3737
> (`os start`) a busy port is a hard error (exit 1) — it never silently
3838
> drifts, because a shifted port breaks reverse-proxy upstreams, `OS_AUTH_URL`
39-
> callbacks, and `OS_TRUSTED_ORIGINS` (CORS). Pin the port explicitly in
39+
> callbacks, and `OS_TRUSTED_ORIGINS` (auth CSRF). Pin the port explicitly in
4040
> production (`OS_PORT=8080 os start`) and keep `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS`
4141
> in sync with it.
4242
@@ -123,8 +123,8 @@ Auth settings precedence:
123123
| Variable | Type | Default | Description |
124124
|:---|:---|:---|:---|
125125
| `OS_CORS_ENABLED` | boolean | `true` | Enable CORS middleware. |
126-
| `OS_CORS_ORIGIN` | csv | `*` | Allowed origins (comma-separated, or `*`). |
127-
| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests. |
126+
| `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`. |
127+
| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests from the admitted origins. Must be `false` to serve `OS_CORS_ORIGIN=*`. |
128128
| `OS_CORS_MAX_AGE` | number | `86400` | Preflight cache TTL in seconds. |
129129

130130
---

‎content/docs/deployment/production-readiness.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma
118118
response `X-Request-Id` header.
119119
- [ ] better-auth session TTL / refresh / revoke verified (curl
120120
checklist in HARDENING.md).
121+
- [ ] CORS admits exactly the cross-origin front-ends you serve. With no
122+
`OS_CORS_ORIGIN` none is admitted (same-origin pages, the Console
123+
included, need no entry); otherwise it lists each origin explicitly,
124+
never `*` — the server refuses `*` at boot while credentials are on.
125+
See [HARDENING.md § CORS](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#cors).
121126
- [ ] Cross-tenant negative tests in CI.
122127
- [ ] Multi-org deployments: tenant isolation is **active**, not degraded —
123128
confirm `features.degradedTenancy` is `false` in `/auth/config`. A

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,10 +411,17 @@ and keep three things in sync with the public origin:
411411

412412
```bash
413413
OS_AUTH_URL=https://app.example.com # auth callbacks / cookie origin
414-
OS_TRUSTED_ORIGINS=https://app.example.com # CORS allow-list
414+
OS_TRUSTED_ORIGINS=https://app.example.com # auth callback / CSRF allow-list
415415
OS_PORT=8080 # must match the proxy upstream
416416
```
417417

418+
CORS needs no entry here: the Console and the API share the public origin,
419+
and with no `OS_CORS_ORIGIN` the server admits no cross-origin caller. Only a
420+
front-end served from **another** origin needs one, listed explicitly
421+
(`OS_CORS_ORIGIN=https://portal.example.com`, comma-separated for several) —
422+
never `*`, which the server refuses at boot while credentials are on. See
423+
[Environment variables → CORS](/docs/deployment/environment-variables#cors).
424+
418425
```text title="Caddyfile"
419426
app.example.com {
420427
reverse_proxy localhost:8080

‎docs/HARDENING.md‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -192,9 +192,34 @@ curl -H "Authorization: Bearer $TOK" $API/data/account
192192

193193
## CORS
194194

195-
CORS is intentionally **not** opinionated by the runtime — it's an
196-
app-level policy that depends on which origins host your front-end.
197-
Configure it on the Hono adapter:
195+
Which cross-origin callers are admitted is an app-level decision — it
196+
depends on which origins host your front-end — so the server that
197+
`os serve` / `os start` mount (`HonoServerPlugin`) admits only the
198+
origins you name:
199+
200+
| Configuration | What is admitted |
201+
| --- | --- |
202+
| 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. |
205+
206+
Same-origin requests need no CORS answer, so the Console under
207+
`/_console` and any page the server itself serves work with no list at
208+
all. A front-end served from **another origin** needs one CORS
209+
variable:
210+
211+
```bash
212+
OS_CORS_ORIGIN=https://app.example.com
213+
```
214+
215+
`OS_TRUSTED_ORIGINS` is auth's own list — the origins it accepts for
216+
its callbacks and CSRF checks — and it admits nothing to CORS. Auth
217+
also trusts every origin listed in `OS_CORS_ORIGIN`, so that one
218+
variable covers a cross-origin front-end that signs in. A list given
219+
through the plugin's `cors.origins` option instead is not seen by
220+
auth, so such a front-end is then also named in `OS_TRUSTED_ORIGINS`.
221+
222+
Composing the Hono adapter yourself, name the origins there as well:
198223

199224
```ts
200225
import { createHonoApp } from '@objectstack/hono';
@@ -209,9 +234,10 @@ const app = createHonoApp({
209234
});
210235
```
211236

212-
Do **not** use `origin: '*'` with `credentials: true` — the
213-
combination is rejected by every modern browser anyway, but the
214-
misconfiguration is a common red flag in audits.
237+
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.
215241

216242
## Auditing
217243

‎packages/plugins/plugin-hono-server/README.md‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,24 @@ await kernel.bootstrap();
3535

3636
The Hono server plugin supports flexible CORS configuration with wildcard pattern matching.
3737

38+
### Default: no cross-origin caller
39+
40+
The CORS middleware is mounted by default, and it admits only the origins you name
41+
(`cors.origins`, else `OS_CORS_ORIGIN`). With neither set, **no cross-origin caller is
42+
admitted**. Same-origin requests need no CORS answer, so pages this server serves are
43+
unaffected. On a development boot (`NODE_ENV=development`, which `os dev` sets), loopback
44+
origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too.
45+
46+
`'*'` while credentials are on (the default) is **refused at init**, so the boot fails.
47+
Name the origins instead, or turn credentials off to serve `'*'` to callers that send no
48+
cookies:
49+
50+
```typescript
51+
kernel.use(new HonoServerPlugin({
52+
cors: { origins: '*', credentials: false }
53+
}));
54+
```
55+
3856
### Basic CORS
3957

4058
```typescript
@@ -79,19 +97,21 @@ CORS can also be configured via environment variables:
7997

8098
```bash
8199
# Single origin
82-
CORS_ORIGIN=https://app.example.com
100+
OS_CORS_ORIGIN=https://app.example.com
83101

84102
# Wildcard patterns (comma-separated)
85-
CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai
103+
OS_CORS_ORIGIN=https://*.objectui.org,https://*.objectstack.ai
86104

87105
# Disable CORS
88-
CORS_ENABLED=false
106+
OS_CORS_ENABLED=false
89107

90108
# Additional options
91-
CORS_CREDENTIALS=true
92-
CORS_MAX_AGE=86400
109+
OS_CORS_CREDENTIALS=true # default; must be false for OS_CORS_ORIGIN=*
110+
OS_CORS_MAX_AGE=86400
93111
```
94112

113+
The legacy `CORS_*` names are still read.
114+
95115
### Disable CORS
96116

97117
```typescript

‎packages/plugins/plugin-hono-server/src/adapter.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ export interface HttpsListenerMaterial {
125125

126126
export interface HonoCorsOptions {
127127
enabled?: boolean;
128+
/**
129+
* The origins admitted to cross-origin requests: exact origins and
130+
* wildcard patterns (`https://*.example.com`, `http://localhost:*`), as an
131+
* array or one comma-separated string. Wins over `OS_CORS_ORIGIN`.
132+
*
133+
* No default list. With neither this nor `OS_CORS_ORIGIN` set, no
134+
* cross-origin caller is admitted — a loopback origin excepted, on a
135+
* development boot only (`NODE_ENV=development`). Same-origin requests
136+
* need no CORS answer and are unaffected either way.
137+
*
138+
* `'*'` (alone or in a list) is refused at init while
139+
* {@link HonoCorsOptions.credentials} is on; it is accepted with
140+
* credentials off.
141+
*/
128142
origins?: string | string[];
129143
methods?: string[];
130144
/**
@@ -144,6 +158,10 @@ export interface HonoCorsOptions {
144158
* exposed unless CORS is disabled entirely.
145159
*/
146160
exposeHeaders?: string[];
161+
/**
162+
* Send `Access-Control-Allow-Credentials: true` to admitted origins.
163+
* Defaults to `OS_CORS_CREDENTIALS !== 'false'`, i.e. on.
164+
*/
147165
credentials?: boolean;
148166
maxAge?: number;
149167
}

0 commit comments

Comments
 (0)