Repository navigation
Commit 02e6d73
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
File tree
- .changeset
- docs
- packages
- adapters/hono
- src
- plugins
- plugin-auth/src
- plugin-hono-server
- src
- qa/http-conformance
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
200 | 200 | | |
201 | 201 | | |
202 | 202 | | |
203 | | - | |
204 | | - | |
| 203 | + | |
| 204 | + | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
| |||
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
222 | | - | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
223 | 226 | | |
224 | 227 | | |
225 | 228 | | |
| |||
235 | 238 | | |
236 | 239 | | |
237 | 240 | | |
238 | | - | |
239 | | - | |
240 | | - | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
241 | 247 | | |
242 | 248 | | |
243 | 249 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
36 | 46 | | |
37 | 47 | | |
38 | 48 | | |
| |||
59 | 69 | | |
60 | 70 | | |
61 | 71 | | |
62 | | - | |
63 | | - | |
| 72 | + | |
| 73 | + | |
64 | 74 | | |
65 | 75 | | |
66 | 76 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
0 commit comments