Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/24809-cors-doors-one-admission-rule.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/plugin-hono-server': minor
'@objectstack/hono': minor
'@objectstack/plugin-auth': minor
---

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

Clause-②: yes (narrowing)

<!-- 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). -->

**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.

- **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`.
- **`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.
- **`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").
- **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.
- **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.
- **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.
- **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.

**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`.
18 changes: 12 additions & 6 deletions docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ origins you name:
| Configuration | What is admitted |
| --- | --- |
| No origin list (neither `OS_CORS_ORIGIN` nor the plugin's `cors.origins`) | **No cross-origin caller.** On a development boot (`NODE_ENV=development`, which `os dev` and `os serve --dev` set), loopback origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too. |
| A list (`OS_CORS_ORIGIN=https://app.example.com`, comma-separated; exact origins or patterns such as `https://*.example.com`) | Those origins, with credentials unless `OS_CORS_CREDENTIALS=false`. Loopback origins are admitted alongside any list. |
| `*` while credentials are on | **Refused at boot** — the boot fails with a message naming the two remedies. |
| 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. |
| `*`, 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. |

Same-origin requests need no CORS answer, so the Console under
`/_console` and any page the server itself serves work with no list at
Expand All @@ -219,7 +219,10 @@ variable covers a cross-origin front-end that signs in. A list given
through the plugin's `cors.origins` option instead is not seen by
auth, so such a front-end is then also named in `OS_TRUSTED_ORIGINS`.

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

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

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

## Auditing

Expand Down
14 changes: 12 additions & 2 deletions packages/adapters/hono/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ const app = createHonoApp({
export default app; // Cloudflare Workers / Bun / Deno / Vercel Edge
```

### CORS

`createHonoApp` admits by the same rule as `@objectstack/plugin-hono-server`'s
`HonoServerPlugin` (`resolveCorsAdmission`), so one configuration gets one answer at
both: a list admits what it names; no list admits no cross-origin caller; a loopback
origin is admitted, beside a list or without one, on a development boot only
(`NODE_ENV=development`). `'*'`, or a pattern whose host part is the bare `*`
(`https://*`), while credentials are on is refused when the app is created; turn
credentials off to serve it to callers that send no cookies.

### Embed into an existing Hono app

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

## Edge runtime notes
Expand Down
188 changes: 188 additions & 0 deletions packages/adapters/hono/src/hono-cors-admission.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* What `createHonoApp`'s CORS answer ADMITS — through the real `hono/cors`
* middleware (`app.request`), never a captured config.
*
* The governing text is `docs/HARDENING.md` § CORS: "Do **not** use
* `origin: '*'` with `credentials: true`", and the go-live checklist's "CORS
* origin list is explicit, not `*`". This door reads the same rule
* `HonoServerPlugin` does (`resolveCorsAdmission` in
* `@objectstack/plugin-hono-server`, which this package's vitest config
* resolves to SOURCE), so a configuration gets the same answer here as there:
*
* 1. no origin list (neither `cors.origin` nor `OS_CORS_ORIGIN`): no
* cross-origin caller is admitted; a loopback origin is admitted on a
* development boot only;
* 2. a configured list is admitted with credentials, and nothing else is — a
* loopback origin beside it on a development boot only;
* 3. `'*'`, or a pattern whose host part is the bare `*`, together with
* credentials is refused when the app is created; without credentials it
* is served.
*
* "Admitted" is read off `Access-Control-Allow-Origin` — the one header a
* browser checks before handing a cross-origin response to the page.
*/

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { Hono } from 'hono';

// `@objectstack/runtime` resolves to `src/__mocks__/runtime.ts` (this
// package's vitest alias): the dispatcher is never reached on `/probe`.
import { createHonoApp, type ObjectStackHonoOptions } from './index';

const FOREIGN = 'https://other.example';
const CONFIGURED = 'https://app.example.com';
const LOOPBACK = 'http://localhost:5173';

/** Every variable the CORS block reads, canonical and legacy spellings. */
const CORS_ENV = [
'OS_CORS_ENABLED', 'CORS_ENABLED',
'OS_CORS_ORIGIN', 'CORS_ORIGIN',
'OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS',
'OS_CORS_MAX_AGE', 'CORS_MAX_AGE',
'NODE_ENV',
] as const;

let saved: Record<string, string | undefined> = {};

beforeEach(() => {
saved = {};
for (const name of CORS_ENV) {
saved[name] = process.env[name];
delete process.env[name];
}
// Not a development boot unless a case says so.
process.env.NODE_ENV = 'production';
});

afterEach(() => {
for (const name of CORS_ENV) {
if (saved[name] === undefined) delete process.env[name];
else process.env[name] = saved[name];
}
});

const kernel = { name: 'cors-admission-kernel' } as unknown as ObjectStackHonoOptions['kernel'];

/** The real `createHonoApp`, with one probe route behind its middleware. */
function app(cors?: ObjectStackHonoOptions['cors']): Hono {
const created = createHonoApp({ kernel, prefix: '/api/v1', ...(cors === undefined ? {} : { cors }) });
created.get('/probe', (c) => c.text('ok'));
return created;
}

/** The `Access-Control-Allow-Origin` a credentialed GET from `origin` is answered with. */
async function allowedOnGet(target: Hono, origin: string): Promise<string | null> {
const res = await target.request('/probe', { headers: { Origin: origin, Cookie: 'session=1' } });
expect(res.status).toBe(200);
return res.headers.get('access-control-allow-origin');
}

/** The same, for the preflight a browser sends ahead of a non-simple request. */
async function allowedOnPreflight(target: Hono, origin: string): Promise<string | null> {
const res = await target.request('/probe', {
method: 'OPTIONS',
headers: {
Origin: origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
},
});
return res.headers.get('access-control-allow-origin');
}

/** What `createHonoApp` threw; an app that was created fails the case. */
function refusal(cors?: ObjectStackHonoOptions['cors']): Error {
let thrown: unknown = null;
try {
app(cors);
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(Error);
return thrown as Error;
}

describe('1. with no origin list configured, no cross-origin caller is admitted', () => {
it('answers a foreign origin with no Access-Control-Allow-Origin, on a request and on a preflight', async () => {
const target = app();
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
expect(await allowedOnPreflight(target, FOREIGN)).toBeNull();
});

it('does not admit a loopback origin either, outside a development boot', async () => {
const target = app();
expect(await allowedOnGet(target, LOOPBACK)).toBeNull();
expect(await allowedOnPreflight(target, LOOPBACK)).toBeNull();
});

it('admits a loopback origin, and still no foreign one, on a development boot', async () => {
process.env.NODE_ENV = 'development';
const target = app();
expect(await allowedOnGet(target, LOOPBACK)).toBe(LOOPBACK);
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
});
});

describe('2. a configured list is admitted with credentials, and nothing else is', () => {
it('via the `cors.origin` option', async () => {
const target = app({ origin: [CONFIGURED] });
const res = await target.request('/probe', { headers: { Origin: CONFIGURED } });
expect(res.headers.get('access-control-allow-origin')).toBe(CONFIGURED);
expect(res.headers.get('access-control-allow-credentials')).toBe('true');
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
});

it('via OS_CORS_ORIGIN, comma-separated, patterns included', async () => {
process.env.OS_CORS_ORIGIN = `${CONFIGURED}, https://*.example.net`;
const target = app();
expect(await allowedOnGet(target, CONFIGURED)).toBe(CONFIGURED);
expect(await allowedOnGet(target, 'https://tenant.example.net')).toBe('https://tenant.example.net');
expect(await allowedOnGet(target, FOREIGN)).toBeNull();
});

it('admits a loopback origin beside the list on a development boot only', async () => {
for (const origin of [[CONFIGURED], 'https://*.example.net']) {
process.env.NODE_ENV = 'production';
expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBeNull();
process.env.NODE_ENV = 'development';
expect(await allowedOnGet(app({ origin }), LOOPBACK)).toBe(LOOPBACK);
}
});
});

describe("3. '*', or a pattern whose host part is the bare `*`, with credentials is refused when the app is created", () => {
it('from the `cors.origin` option, with credentials left at their default, naming the entry', () => {
for (const entry of ['*', 'https://*', '*://*', 'http://*:*']) {
const err = refusal({ origin: entry });
expect(err.message).toContain(`'${entry}'`);
expect(err.message).toContain('OS_CORS_ORIGIN');
expect(err.message).toContain('OS_CORS_CREDENTIALS');
}
});

it('from OS_CORS_ORIGIN, alone or inside a list', () => {
for (const value of ['*', ' * ', `${CONFIGURED},*`, 'https://*', `${CONFIGURED}, *://*`]) {
process.env.OS_CORS_ORIGIN = value;
refusal();
}
});

it('with credentials named explicitly, by option or by OS_CORS_CREDENTIALS', () => {
refusal({ origin: ['*'], credentials: true });
process.env.OS_CORS_ORIGIN = 'https://*';
process.env.OS_CORS_CREDENTIALS = 'true';
refusal();
});

it('serves them without credentials, admitting any origin with no credentials header', async () => {
process.env.OS_CORS_CREDENTIALS = 'false';
for (const value of ['*', 'https://*']) {
process.env.OS_CORS_ORIGIN = value;
const res = await app().request('/probe', { headers: { Origin: FOREIGN } });
expect(res.headers.get('access-control-allow-origin')).toBe(FOREIGN);
expect(res.headers.get('access-control-allow-credentials')).toBeNull();
}
});
});
Loading
Loading