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' ;
44import { RestServerConfig } from '@objectstack/spec/api' ;
55import { registerCurrentUserEndpoints } from './current-user-endpoints' ;
66import {
@@ -13,7 +13,7 @@ import { cors } from 'hono/cors';
1313import { serveStatic } from '@hono/node-server/serve-static' ;
1414import * as fs from 'fs' ;
1515import * as path from 'path' ;
16- import { createOriginMatcher , hasWildcardPattern , isLocalhostOrigin } from './pattern-matcher' ;
16+ import { createOriginMatcher , isLocalhostOrigin , normalizeOriginPatterns } from './pattern-matcher' ;
1717import type { HttpsListenerMaterial } from './adapter' ;
1818import { readEnvWithDeprecation } from '@objectstack/types' ;
1919import {
@@ -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