Skip to content

Commit 7cee813

Browse files
committed
fix(plugin-hono-server)!: the CORS default admits no cross-origin caller; '*' with credentials is refused at init
With no origin list configured (no cors.origins, no OS_CORS_ORIGIN), the transport's CORS answer now admits no cross-origin caller; a loopback origin is admitted on a development boot only. A list holding '*' while credentials are on is refused at init, so the boot fails naming both remedies. Claude-Session: https://claude.ai/code/session_01LmjU2x1faLZ39DVTFyVca6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b1bb0d2 commit 7cee813

2 files changed

Lines changed: 80 additions & 30 deletions

File tree

‎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
}

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

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { Plugin, PluginContext } from '@objectstack/core';
3+
import { Plugin, PluginContext, isDevelopmentBoot } from '@objectstack/core';
44
import { RestServerConfig } from '@objectstack/spec/api';
55
import { registerCurrentUserEndpoints } from './current-user-endpoints';
66
import {
@@ -13,7 +13,7 @@ import { cors } from 'hono/cors';
1313
import { serveStatic } from '@hono/node-server/serve-static';
1414
import * as fs from 'fs';
1515
import * as path from 'path';
16-
import { createOriginMatcher, hasWildcardPattern, isLocalhostOrigin } from './pattern-matcher';
16+
import { createOriginMatcher, isLocalhostOrigin, normalizeOriginPatterns } from './pattern-matcher';
1717
import type { HttpsListenerMaterial } from './adapter';
1818
import { readEnvWithDeprecation } from '@objectstack/types';
1919
import {
@@ -72,7 +72,18 @@ export interface HonoPluginOptions {
7272

7373
/**
7474
* CORS configuration. Set to `false` to disable entirely.
75-
* Enabled by default with origin '*'.
75+
*
76+
* Mounted by default. It admits the origins it is told about —
77+
* {@link HonoCorsOptions.origins}, else `OS_CORS_ORIGIN` — and, with
78+
* neither set, **no cross-origin caller**: a same-origin request needs no
79+
* CORS answer, and a loopback origin (`localhost` / `127.0.0.1` / `[::1]`,
80+
* any port) is admitted on a development boot only (`NODE_ENV` exactly
81+
* `development`, which `os dev` and `os serve --dev` set).
82+
*
83+
* `'*'` together with credentials (on unless turned off) is refused at
84+
* init, so the boot fails: name the origins instead, or turn credentials
85+
* off to serve `'*'` to anonymous callers.
86+
*
7687
* Can also be controlled via environment variables:
7788
* OS_CORS_ENABLED, OS_CORS_ORIGIN, OS_CORS_CREDENTIALS, OS_CORS_MAX_AGE
7889
* (legacy CORS_* names still honoured with a deprecation warning).
@@ -357,50 +368,71 @@ export class HonoServerPlugin implements Plugin {
357368
}
358369

359370
// ─── CORS Middleware ──────────────────────────────────────────────────
360-
// Enabled by default. Controlled via options.cors or environment variables.
371+
// Mounted by default. Controlled via options.cors or environment variables.
372+
//
373+
// What it ADMITS is decided here, and the governing text is
374+
// `docs/HARDENING.md` § CORS: never `'*'` with credentials, and an origin
375+
// list that is explicit. So the answer follows what was configured, and
376+
// nothing else:
377+
//
378+
// - a list (`cors.origins`, else `OS_CORS_ORIGIN`) — the pattern matcher
379+
// admits exactly that list (its own loopback convenience included);
380+
// - no list — no cross-origin caller. Nothing here needs the host's own
381+
// origin: a browser applies CORS to cross-origin requests only, so the
382+
// Console and every page this server serves are untouched. A loopback
383+
// origin is admitted on a development boot only — `isDevelopmentBoot`,
384+
// the same reading the auth secret's development fallback uses; the
385+
// transport has no `--dev` flag of its own, and `os serve --dev` sets
386+
// `NODE_ENV=development` before any plugin initialises;
387+
// - `'*'` in the list while credentials are on — refused, here, so the
388+
// boot fails (`kernel.bootstrap()` rethrows an `init()` throw, the
389+
// route `@objectstack/plugin-dev`'s production guard takes). It used
390+
// to be turned into "reflect whatever origin asked", silently.
361391
const corsDisabledByEnv = readEnvWithDeprecation('OS_CORS_ENABLED', 'CORS_ENABLED', { silent: true }) === 'false';
362392
if (this.options.cors !== false && !corsDisabledByEnv) {
363393
const corsOpts = typeof this.options.cors === 'object' ? this.options.cors : {};
364394
const enabled = corsOpts.enabled ?? true;
365395

366396
if (enabled) {
367-
let configuredOrigin: string | string[];
397+
// `undefined` = nobody named a list: the default described above.
398+
let configuredOrigin: string | string[] | undefined;
368399
const corsOriginEnv = readEnvWithDeprecation('OS_CORS_ORIGIN', 'CORS_ORIGIN', { silent: true });
369400
if (corsOpts.origins) {
370401
configuredOrigin = corsOpts.origins;
371402
} else if (corsOriginEnv) {
372403
const envOrigin = corsOriginEnv.trim();
373404
configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin;
374-
} else {
375-
configuredOrigin = '*';
376405
}
377406

378407
const credentials = corsOpts.credentials ?? (readEnvWithDeprecation('OS_CORS_CREDENTIALS', 'CORS_CREDENTIALS', { silent: true }) !== 'false');
379408
const maxAgeEnv = readEnvWithDeprecation('OS_CORS_MAX_AGE', 'CORS_MAX_AGE', { silent: true });
380409
const maxAge = corsOpts.maxAge ?? (maxAgeEnv ? parseInt(maxAgeEnv, 10) : 86400);
381410

382-
// Determine origin handler based on configuration.
383-
// Always use a function so that localhost origins are
384-
// automatically allowed regardless of the configured
385-
// pattern list (handled inside matchOriginPattern /
386-
// createOriginMatcher).
387-
let origin: string | string[] | ((origin: string) => string | undefined | null);
388-
389-
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
390-
// For wildcard patterns (like "https://*.example.com"), always use a matcher function.
391-
// For exact origins, we can pass them directly as string/array.
392-
if (configuredOrigin === '*' && credentials) {
393-
// Credentials mode with '*' - reflect the request origin
394-
origin = (requestOrigin: string) => requestOrigin || '*';
395-
} else if (hasWildcardPattern(configuredOrigin)) {
396-
// Wildcard patterns (including better-auth style patterns like "https://*.objectui.org")
397-
// Use pattern matcher to support subdomain and port wildcards
398-
origin = createOriginMatcher(configuredOrigin);
411+
// Always a function, so `hono/cors` never sees a bare `'*'` and
412+
// every answer passes through one of the two branches below.
413+
let origin: (requestOrigin: string) => string | null;
414+
let admits: string | string[];
415+
416+
if (configuredOrigin === undefined) {
417+
const developmentBoot = isDevelopmentBoot(undefined);
418+
origin = (requestOrigin: string) =>
419+
developmentBoot && isLocalhostOrigin(requestOrigin) ? requestOrigin : null;
420+
admits = developmentBoot ? 'loopback origins only (development boot)' : 'no cross-origin caller';
399421
} else {
400-
// Exact origin(s) — wrap in a function so localhost is
401-
// still auto-allowed via the matcher.
402-
const matcher = createOriginMatcher(configuredOrigin);
403-
origin = (requestOrigin: string) => matcher(requestOrigin);
422+
if (credentials && normalizeOriginPatterns(configuredOrigin).includes('*')) {
423+
throw new Error(
424+
"@objectstack/plugin-hono-server refuses CORS origin '*' with credentials: together "
425+
+ 'they admit every origin for credentialed cross-origin requests. Name the origins '
426+
+ 'that may call with credentials (OS_CORS_ORIGIN=https://app.example.com, '
427+
+ 'comma-separated, or the `cors.origins` option), or set OS_CORS_CREDENTIALS=false '
428+
+ "(or `cors.credentials: false`) to serve '*' without credentials.",
429+
);
430+
}
431+
// Exact origins and wildcard patterns ("https://*.example.com",
432+
// "http://localhost:*") alike — one matcher, the one the
433+
// serverless preflight short-circuit shares.
434+
origin = createOriginMatcher(configuredOrigin);
435+
admits = configuredOrigin;
404436
}
405437

406438
const rawApp = this.server.getRawApp();
@@ -415,15 +447,15 @@ export class HonoServerPlugin implements Plugin {
415447
]));
416448

417449
rawApp.use('*', cors({
418-
origin: origin as any,
450+
origin,
419451
allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
420452
allowHeaders,
421453
exposeHeaders,
422454
credentials,
423455
maxAge,
424456
}));
425457

426-
ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials });
458+
ctx.logger.debug('CORS middleware enabled', { origin: admits, credentials });
427459
}
428460
}
429461

0 commit comments

Comments
 (0)