Repository navigation
fix(cors)!: one CORS admission rule at every door — createHonoApp's default, loopback beside a list, all-host patterns, auth's trusted origins - #24873
Conversation
… fix The pins this card's fix turns green, committed first so each one can be read red against the base's source: - plugin-hono-server: a configured list admits a loopback origin beside it on a development boot only, and a pattern whose host part is the bare `*` is refused with credentials as `'*'` is; - @objectstack/hono: createHonoApp answers the same configurations the same way as HonoServerPlugin; - plugin-auth: OS_CORS_ORIGIN entries reach better-auth's trusted origins one by one, and an entry that admits every host never does; - http-conformance: every door that mounts CORS or reads OS_CORS_ORIGIN is listed and asked the same rows (it gains a plugin-auth devDependency and a source alias for that door). Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
… list on a development boot only; an all-host pattern refused with credentials `docs/HARDENING.md` § CORS governs: never `'*'` with credentials, and an explicit origin list. - plugin-hono-server: `resolveCorsAdmission` (new, `cors-admission.ts`) is the one rule. `HonoServerPlugin` reads it in `init()`. `createOriginMatcher` admits a loopback origin beside a list on a development boot only (`isDevelopmentBoot`, read when the matcher is created unless passed), and `matchOriginPattern` matches the pattern and nothing else. `isAllHostOriginPattern` (new) names `'*'` and a pattern whose host part is the bare `*`; with credentials on, any such entry is refused, as `'*'` was. - @objectstack/hono: `createHonoApp` reads the same rule, so with no list it admits no cross-origin caller, and it refuses the same configurations when the app is created. - plugin-auth: `OS_CORS_ORIGIN` entries reach better-auth's trusted origins one by one; an entry that admits every host never does. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
…e one admission rule as shipped - HARDENING § CORS: the list row says loopback is admitted beside a list on a development boot only; the refusal row and the closing paragraph name the all-host patterns and both doors; the createHonoApp paragraph says the adapter answers by the same rule. - plugin-hono-server README: the same, and where the rule is exported. - @objectstack/hono README: the CORS defaults (no list, credentials on) and the shared rule. - One changeset for the three published packages, minor, breaking, with the Clause-② line and the ADR-0087 disposition. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
…rs-family-closure
An exact-origin list at createHonoApp never admitted a loopback origin; under the shared rule it does on a development boot, as HonoServerPlugin already did. The changeset says so. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 4 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 22 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e83c8e2e4903ddfe8860e1f92ef796c66bbae630 && git checkout e83c8e2e4903ddfe8860e1f92ef796c66bbae630
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fcb40ad673a08b231d34190a60d692f462c5df23 0ffd23e64de690718b939fe22ef8898c4c13f05f && git checkout -B drift-repro fcb40ad673a08b231d34190a60d692f462c5df23 && git merge --no-ff 0ffd23e64de690718b939fe22ef8898c4c13f05f
node scripts/docs-audit/affected-docs.mjs --json fcb40ad673a08b231d34190a60d692f462c5df23
|
…ed exports are named The seat's answer to the dev report's first open question: the diff adds exports-addressable symbols to @objectstack/plugin-hono-server, so the line reads `yes (narrowing)`. The changeset now names them and why they exist: one rule shared by two published doors. The level stays minor and breaking. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Net diff read as merge-base ① Derived judgmentsDisclosure (security card, ruling on #9387, comment 5323310017).
Check-runs on the head (latest run per name, 41 names, read 2026-10-20T12:45:56Z).
The one rule, read. The matcher, read. The PR body's
One row the table omits. "Does any configuration that admitted an origin it should keep admitting now refuse it?" No. Every narrowing the diff makes is one the card asked for: the adapter's no-list default, loopback beside a list outside development at both doors (and at the cloud preflight, below), and the all-host class with credentials. Read for an unintended refusal: an exact origin or a host-naming pattern is admitted as before at both doors (matcher exact arm and expansion unchanged); The "Unchanged" list. Each bullet is TRUE against the code: host-naming patterns and exact origins unchanged at both doors and in auth; the plugin's development-boot loopback beside a list and its no-list answer as #24738 left them; all-host entries with credentials off still served; the empty list; The two copies of the all-host predicate. Public surface. The prose faces, each against the shipped behaviour.
The pins, by what they assert. The plugin's describes 5 and 6, the pattern-matcher cases (loopback-only-by-name, ② Semver level
③ Boundary flagsThe dev's flags (report
Landing. The 18.0.0 ruling on #24738 and ruling A ( Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 · ACCEPT
Accepted deviations: the review's ③1–③6 and ③13.
Owed before landing:
Out-of-scope dispositions (one line each):
Landing: through the merge queue by this seat once items 1–3 hold. Generated by Claude Code |
…ion that shipped it, and add the comma-separated cors.origin row 18.0.0 was versioned on main after this branch's merge base, so "in this release" would read one release off in the CHANGELOG entry that carries this changeset; the sentence now names the change by 18.0.0. createHonoApp's cors.origin written as one comma-separated string of exact origins admitted no origin before and admits each listed origin now. The changeset gains that row, and its "Unchanged" bullet no longer claims the exact-origin answer is unchanged in that one spelling. Prose only. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
席位核对 · patch round adopted
Generated by Claude Code |
席位核对 · the maintainer's word recorded · landing
Landing: armed for the merge queue in the stroke after this comment. Generated by Claude Code |
Fixes #24809
Clause-②: yes (narrowing)
What changes
Every door in this repository that mounts CORS or reads
OS_CORS_ORIGINnow gives one configuration one answer. The governing text isdocs/HARDENING.md§ CORS: "Do not useorigin: '*'withcredentials: 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 comment6288561947name.resolveCorsAdmission(new,packages/plugins/plugin-hono-server/src/cors-admission.ts) decides what a transport door admits from the door's own options and theOS_CORS_*environment.HonoServerPluginreads it ininit().@objectstack/hono'screateHonoAppreads it when the app is created. Each door only maps its own option names (cors.originsandcors.origin) onto it, and nothing is copied.createHonoApp. Its old default was the combination security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738 removed fromHonoServerPlugin: with no list, every origin was admitted with credentials, and'*'with credentials on was accepted. It now answers exactly asHonoServerPlugindoes.matchOriginPatternadmitted every loopback origin ahead of any pattern, in every mode. It now matches the pattern and nothing else.createOriginMatcheradmits a loopback origin beside a non-empty list on a development boot only.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.auth-manager.tstested the wholeOS_CORS_ORIGINvalue 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.packages/qa/http-conformance/src/cors-doors.conformance.test.tslists the three doors and asks each one the same nine configurations.The development boot is
isDevelopmentBootfrom@objectstack/core(NODE_ENVexactlydevelopment), 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 importscreateOriginMatcherwith 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 tofalsewould 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" meansNODE_ENVis exactlydevelopment.createHonoAppcreateHonoAppcreateHonoAppOS_CORS_CREDENTIALS=false, not a development bootcreateHonoAppOS_CORS_CREDENTIALS=false, development bootcreateHonoApp*(option orOS_CORS_ORIGIN, alone or among other entries), credentials oncreateHonoAppthrowsHonoServerPlugin,createHonoApp*, credentials oninit()throws and the boot fails, orcreateHonoAppthrowsHonoServerPlugin,createHonoAppHonoServerPlugincreateHonoAppcreateHonoAppcors.originoption as one comma-separated string of exact origins (no*), credentials on or offOS_CORS_ORIGINwritten the same way was already split, and is unchanged)yesline, 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_ORIGINholding*among other entries*entered better-auth's trusted origins*is dropped; the other entries are trusted as beforeAuthManagerOS_CORS_ORIGINof*written with surrounding space*entered the trusted originsAuthManagerOS_CORS_ORIGINholding a pattern whose host part is the bare*Unchanged:
https://*.example.com,http://localhost:*), is admitted as before, with or without credentials, at both transport doors, and auth trusts it as before.HonoServerPluginadmits loopback origins beside a list as before. With no list,HonoServerPluginanswers as security: the HTTP server's default CORS answer admits every origin for credentialed requests (detail withheld under the disclosure ruling) #24738 left it.*, or an all-host pattern, with credentials off still boots and admits without credentials.cors.origins: [], orOS_CORS_ORIGINof whitespace) admits nothing, loopback included, in every mode, as before.cors: false,cors.enabled: falseandOS_CORS_ENABLED=falsestill mount nothing. Allowed and exposed headers, methods and max-age are unchanged. Preflights are still answered204, admitted or not.OS_TRUSTED_ORIGINSadmits nothing to CORS (ruling A,6288093770).Public surface added (outside the table).
@objectstack/plugin-hono-servergains these exports so that one rule is shared by two published doors,HonoServerPluginandcreateHonoApp. They, and thecreateHonoAppdevelopment-boot row above, are why theClause-②line readsyes:resolveCorsAdmission, with its typesCorsAdmission,CorsAdmissionInputandCorsAdmissionDoor;isAllHostOriginPattern;OriginMatcherOptions, the type ofcreateOriginMatcher's new optional second parameter.The refusal message now names the entry, and
createHonoAppgains the refusal. Nothing is removed or renamed, and no spec key moves.Mechanism (measured)
a9c5f629a(baseb3fc122f0plus the test files only), and run against that tree's source.plugin-hono-serverand@objectstack/honowere run from source, andhttp-conformancefrom adist/built at that commit:plugin-hono-serversrc/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/honosrc/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, andcreateHonoAppdid not throw for*or an all-host pattern with credentials.plugin-authsrc/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-conformancesrc/cors-doors.conformance.test.ts:Tests 11 failed | 7 passed (18).1.7.3, usingmatchesOriginPatternindist/auth/trusted-origins.mjs. better-auth readshttps://*and*://*each as matching every origin of their scheme, as it reads*(andhttps://*.*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 onplugin-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.*", defined narrowly. It is the regular expression inisAllHostOriginPattern: an optionalscheme://, then only*characters, then an optional:portor:*. A pattern that names any part of the host is not in the class, includinghttps://*.*. No general pattern validator is added.pattern-matcher.test.ts("should always allow localhost origins regardless of configured patterns", and127.0.0.1besidehttp://localhost:*). They pinned the old convenience, and they now pin the development-only rule. No other test, example or shipped code relies on it:Access-Control-Allow-Originassertions in the repo are@objectstack/hono'shono.test.ts, whose loopback case nameshttp://localhost:*in its own list, andplugin-hono-server's pin file;/apiproxy in dev, andVITE_SERVER_URLempty in both.envfiles; read-only,../objectuiatcd4623d06).packages/for non-test readers ofOS_CORS_ORIGINand importers ofhono/corsreturns three doors:hono-plugin.ts,adapters/hono/src/index.tsandauth-manager.ts. No other adapter mounts CORS.Tests
All runs are on
fb509fc0a(this branch merged withorigin/main9a06f0be0) 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-serversrc/cors-default-admission.test.ts, describes 5 and 6:OS_CORS_ORIGIN;http://localhost:*admits it in every mode, and nothing else;init(), and the message names the entry and both remedies;https://*.example.comboots with credentials.plugin-hono-serversrc/pattern-matcher.test.ts:matchOriginPatternmatches the pattern only;createOriginMatcheradmits loopback beside a list on a development boot only, and an empty list admits nothing;NODE_ENVexactlydevelopment, read at creation;isAllHostOriginPatternnames the class and not its neighbours.@objectstack/honosrc/hono-cors-admission.test.ts:createHonoAppanswers rows 1 to 3 of the plugin's pin file the same way, with its refusal thrown when the app is created.plugin-authsrc/auth-manager.cors-origin-trust.test.ts, read off a real better-auth instance:*entry among real origins, a spaced*, and each all-host pattern never enter the trusted origins;http-conformancesrc/cors-doors.conformance.test.ts, for each of nine configurations:*orhttps://*.Suite runs, each command exiting 0:
pnpm --filter @objectstack/plugin-hono-server test: 35 files, 448 tests passed.typecheckpassed.pnpm --filter @objectstack/hono test: 12 files, 225 tests passed.typecheckpassed.pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2: 171 files, 3264 passed and 10 skipped.typecheckpassed.pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2: 19 files, 246 tests passed.typecheckpassed.tsc -p tsconfig.test.json --listFiles.Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 85 families for this diff onfb509fc0a, and all 85 ran.pnpm check:dual-build-cjs-loadsexited 3 withPREREQUISITE 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'sLint & Repo Gatesruns it.--ranreconciliation, with every exit code recorded:85 derived, 84 run, 1 NOT-MEASURED, 0 UNRUN.bb2fde296, after the changeset's last edit. Each of these exited 0:check-adr-0087-registration.mjs --base origin/main, with theno-migration-prescriptionexemption noticed;check-changeset-no-major.mjs --base origin/main;check-empty-changeset.mjs --base origin/main;check-changeset-fixed.mjs;check:doc-authoring.pnpm lintonbb2fde296: exit 0.check-platform-checklist.mjs --changed-fromwas not run, because no exported symbol is renamed or removed.Changeset
.changeset/24809-cors-doors-one-admission-rule.mdbumps@objectstack/plugin-hono-server,@objectstack/honoand@objectstack/plugin-authasminor(thefixedgroup; onmainit is at18.0.0, versioned in06cab4849and tagged, and.changeset/pre.jsonis gone, so no pre-release tag applies). It is marked BREAKING: an accept-set narrowing shipped asminorunder the launch-window convention. It carries theClause-②line and the ADR-0087 dispositionnot-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 atcreateHonoApp.@objectstack/http-conformanceis private and gains only a test, a devDependency on@objectstack/plugin-authand a source alias for it.18.0.0was versioned after this branch's merge base, so the next version pass consumes this changeset, not18.0.0; landing waits on the maintainer's word.Acceptance notes
18.0.0is 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 ACCEPT6291362699, item 2), which also covers whether18.0.0closed the launch window theminorgrade rests on; until thenminorwith the BREAKING banner is the form the gate enforces onmain. Built as if it lands onmainnow.DOORS. Discovering doors by scanning every package's source would need a cross-package input radius over all ofpackages/**/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_ORIGINrow) says "*is refused at boot unlessOS_CORS_CREDENTIALS=false". That is still true, but it does not name the all-host patterns. It is outside this claim's docs face. Thecli.mdx"(CORS)" line is held off as before.serve.ts(about:4182) says better-auth's glob does not cross dot separators. Measured on1.7.3, it does:https://*.example.commatcheshttps://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.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