Skip to content
Merged
17 changes: 17 additions & 0 deletions .changeset/24738-cors-default-no-cross-origin-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/plugin-hono-server': minor
---

fix(plugin-hono-server)!: the CORS default admits no cross-origin caller, and `'*'` with credentials is refused at boot

Clause-②: no (narrowing)

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

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

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

**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`.
6 changes: 3 additions & 3 deletions content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
> +100) and the banner prints the actual bound port. In **production**
> (`os start`) a busy port is a hard error (exit 1) — it never silently
> drifts, because a shifted port breaks reverse-proxy upstreams, `OS_AUTH_URL`
> callbacks, and `OS_TRUSTED_ORIGINS` (CORS). Pin the port explicitly in
> callbacks, and `OS_TRUSTED_ORIGINS` (auth CSRF). Pin the port explicitly in
> production (`OS_PORT=8080 os start`) and keep `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS`
> in sync with it.

Expand Down Expand Up @@ -123,8 +123,8 @@ Auth settings precedence:
| Variable | Type | Default | Description |
|:---|:---|:---|:---|
| `OS_CORS_ENABLED` | boolean | `true` | Enable CORS middleware. |
| `OS_CORS_ORIGIN` | csv | `*` | Allowed origins (comma-separated, or `*`). |
| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests. |
| `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`. |
| `OS_CORS_CREDENTIALS` | boolean | `true` | Allow credentialed cross-origin requests from the admitted origins. Must be `false` to serve `OS_CORS_ORIGIN=*`. |
| `OS_CORS_MAX_AGE` | number | `86400` | Preflight cache TTL in seconds. |

---
Expand Down
5 changes: 5 additions & 0 deletions content/docs/deployment/production-readiness.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ the [HARDENING.md recipes](https://github.com/objectstack-ai/objectstack/blob/ma
response `X-Request-Id` header.
- [ ] better-auth session TTL / refresh / revoke verified (curl
checklist in HARDENING.md).
- [ ] CORS admits exactly the cross-origin front-ends you serve. With no
`OS_CORS_ORIGIN` none is admitted (same-origin pages, the Console
included, need no entry); otherwise it lists each origin explicitly,
never `*` — the server refuses `*` at boot while credentials are on.
See [HARDENING.md § CORS](https://github.com/objectstack-ai/objectstack/blob/main/docs/HARDENING.md#cors).
- [ ] Cross-tenant negative tests in CI.
- [ ] Multi-org deployments: tenant isolation is **active**, not degraded —
confirm `features.degradedTenancy` is `false` in `/auth/config`. A
Expand Down
9 changes: 8 additions & 1 deletion content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -411,10 +411,17 @@ and keep three things in sync with the public origin:

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

CORS needs no entry here: the Console and the API share the public origin,
and with no `OS_CORS_ORIGIN` the server admits no cross-origin caller. Only a
front-end served from **another** origin needs one, listed explicitly
(`OS_CORS_ORIGIN=https://portal.example.com`, comma-separated for several) —
never `*`, which the server refuses at boot while credentials are on. See
[Environment variables → CORS](/docs/deployment/environment-variables#cors).

```text title="Caddyfile"
app.example.com {
reverse_proxy localhost:8080
Expand Down
38 changes: 32 additions & 6 deletions docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,9 +192,34 @@ curl -H "Authorization: Bearer $TOK" $API/data/account

## CORS

CORS is intentionally **not** opinionated by the runtime — it's an
app-level policy that depends on which origins host your front-end.
Configure it on the Hono adapter:
Which cross-origin callers are admitted is an app-level decision — it
depends on which origins host your front-end — so the server that
`os serve` / `os start` mount (`HonoServerPlugin`) admits only the
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. |

Same-origin requests need no CORS answer, so the Console under
`/_console` and any page the server itself serves work with no list at
all. A front-end served from **another origin** needs one CORS
variable:

```bash
OS_CORS_ORIGIN=https://app.example.com
```

`OS_TRUSTED_ORIGINS` is auth's own list — the origins it accepts for
its 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`.

Composing the Hono adapter yourself, name the origins there as well:

```ts
import { createHonoApp } from '@objectstack/hono';
Expand All @@ -209,9 +234,10 @@ const app = createHonoApp({
});
```

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

## Auditing

Expand Down
30 changes: 25 additions & 5 deletions packages/plugins/plugin-hono-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ await kernel.bootstrap();

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

### Default: no cross-origin caller

The CORS middleware is mounted by default, and it admits only the origins you name
(`cors.origins`, else `OS_CORS_ORIGIN`). With neither set, **no cross-origin caller is
admitted**. Same-origin requests need no CORS answer, so pages this server serves are
unaffected. On a development boot (`NODE_ENV=development`, which `os dev` sets), loopback
origins (`localhost`, `127.0.0.1`, `[::1]`, any port) are admitted too.

`'*'` while credentials are on (the default) is **refused at init**, so the boot fails.
Name the origins instead, or turn credentials off to serve `'*'` to callers that send no
cookies:

```typescript
kernel.use(new HonoServerPlugin({
cors: { origins: '*', credentials: false }
}));
```

### Basic CORS

```typescript
Expand Down Expand Up @@ -79,19 +97,21 @@ CORS can also be configured via environment variables:

```bash
# Single origin
CORS_ORIGIN=https://app.example.com
OS_CORS_ORIGIN=https://app.example.com

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

# Disable CORS
CORS_ENABLED=false
OS_CORS_ENABLED=false

# Additional options
CORS_CREDENTIALS=true
CORS_MAX_AGE=86400
OS_CORS_CREDENTIALS=true # default; must be false for OS_CORS_ORIGIN=*
OS_CORS_MAX_AGE=86400
```

The legacy `CORS_*` names are still read.

### Disable CORS

```typescript
Expand Down
18 changes: 18 additions & 0 deletions packages/plugins/plugin-hono-server/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,20 @@ export interface HttpsListenerMaterial {

export interface HonoCorsOptions {
enabled?: boolean;
/**
* The origins admitted to cross-origin requests: exact origins and
* wildcard patterns (`https://*.example.com`, `http://localhost:*`), as an
* array or one comma-separated string. Wins over `OS_CORS_ORIGIN`.
*
* No default list. With neither this nor `OS_CORS_ORIGIN` set, no
* cross-origin caller is admitted — a loopback origin excepted, on a
* development boot only (`NODE_ENV=development`). Same-origin requests
* need no CORS answer and are unaffected either way.
*
* `'*'` (alone or in a list) is refused at init while
* {@link HonoCorsOptions.credentials} is on; it is accepted with
* credentials off.
*/
origins?: string | string[];
methods?: string[];
/**
Expand All @@ -144,6 +158,10 @@ export interface HonoCorsOptions {
* exposed unless CORS is disabled entirely.
*/
exposeHeaders?: string[];
/**
* Send `Access-Control-Allow-Credentials: true` to admitted origins.
* Defaults to `OS_CORS_CREDENTIALS !== 'false'`, i.e. on.
*/
credentials?: boolean;
maxAge?: number;
}
Expand Down
Loading
Loading