Skip to content

Commit ca8fe14

Browse files
committed
feat: implement CORS support in Hono server and auth manager for enhanced security
1 parent 34bd064 commit ca8fe14

File tree

4 files changed

+176
-35
lines changed

4 files changed

+176
-35
lines changed

packages/adapters/hono/src/index.ts

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

33
import { Hono } from 'hono';
4+
import { cors } from 'hono/cors';
45
import { type ObjectKernel, HttpDispatcher, HttpDispatcherResult } from '@objectstack/runtime';
56

7+
export interface ObjectStackHonoCorsOptions {
8+
/** Enable or disable CORS. Defaults to true. */
9+
enabled?: boolean;
10+
/** Allowed origins. Defaults to env `CORS_ORIGIN` or '*'. Comma-separated string or array. */
11+
origin?: string | string[];
12+
/** Allowed methods. */
13+
methods?: string[];
14+
/** Allow credentials (cookies, authorization headers). */
15+
credentials?: boolean;
16+
/** Preflight cache max-age in seconds. */
17+
maxAge?: number;
18+
/** Allowed headers. */
19+
allowHeaders?: string[];
20+
/** Exposed headers. */
21+
exposeHeaders?: string[];
22+
}
23+
624
export interface ObjectStackHonoOptions {
725
kernel: ObjectKernel;
826
prefix?: string;
27+
/** CORS configuration. Set to `false` to disable entirely. */
28+
cors?: ObjectStackHonoCorsOptions | false;
929
}
1030

1131
/**
@@ -49,6 +69,52 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
4969
const prefix = options.prefix || '/api';
5070
const dispatcher = new HttpDispatcher(options.kernel);
5171

72+
// ─── CORS Middleware ──────────────────────────────────────────────────────
73+
// Enabled by default. Controlled via options.cors or environment variables:
74+
// CORS_ENABLED – "false" to disable (default: true)
75+
// CORS_ORIGIN – comma-separated origins or "*" (default: "*")
76+
// CORS_CREDENTIALS – "false" to disallow credentials (default: true)
77+
// CORS_MAX_AGE – preflight cache seconds (default: 86400)
78+
const corsDisabledByEnv = process.env.CORS_ENABLED === 'false';
79+
if (options.cors !== false && !corsDisabledByEnv) {
80+
const corsOpts = typeof options.cors === 'object' ? options.cors : {};
81+
const enabled = corsOpts.enabled ?? true;
82+
83+
if (enabled) {
84+
// Resolve origins: options > env > default '*'
85+
let configuredOrigin: string | string[];
86+
if (corsOpts.origin) {
87+
configuredOrigin = corsOpts.origin;
88+
} else if (process.env.CORS_ORIGIN) {
89+
const envOrigin = process.env.CORS_ORIGIN.trim();
90+
configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin;
91+
} else {
92+
configuredOrigin = '*';
93+
}
94+
95+
const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false');
96+
const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);
97+
98+
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
99+
// Use a function to reflect the request's Origin header instead.
100+
let origin: string | string[] | ((origin: string) => string | undefined | null);
101+
if (credentials && configuredOrigin === '*') {
102+
origin = (requestOrigin: string) => requestOrigin || '*';
103+
} else {
104+
origin = configuredOrigin;
105+
}
106+
107+
app.use('*', cors({
108+
origin: origin as any,
109+
allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
110+
allowHeaders: corsOpts.allowHeaders || ['Content-Type', 'Authorization', 'X-Requested-With'],
111+
exposeHeaders: corsOpts.exposeHeaders || [],
112+
credentials,
113+
maxAge,
114+
}));
115+
}
116+
}
117+
52118
const errorJson = (c: any, message: string, code: number = 500) => {
53119
return c.json({ success: false, error: { message, code } }, code);
54120
};

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,23 @@ export class AuthManager {
157157
plugins: this.buildPluginList(),
158158

159159
// Trusted origins for CSRF protection (supports wildcards like "https://*.example.com")
160-
...(this.config.trustedOrigins?.length ? { trustedOrigins: this.config.trustedOrigins } : {}),
160+
// Auto-includes origins from CORS_ORIGIN env var so CORS and CSRF stay in sync.
161+
...(() => {
162+
const origins: string[] = [...(this.config.trustedOrigins || [])];
163+
// Sync with CORS_ORIGIN env var (comma-separated)
164+
const corsOrigin = process.env.CORS_ORIGIN;
165+
if (corsOrigin && corsOrigin !== '*') {
166+
corsOrigin.split(',').map(s => s.trim()).filter(Boolean).forEach(o => {
167+
if (!origins.includes(o)) origins.push(o);
168+
});
169+
}
170+
// When CORS allows all origins (default) and no explicit trustedOrigins,
171+
// trust all localhost ports in development for convenience.
172+
if (!origins.length && (!corsOrigin || corsOrigin === '*')) {
173+
origins.push('http://localhost:*');
174+
}
175+
return origins.length ? { trustedOrigins: origins } : {};
176+
})(),
161177

162178
// Advanced options (cross-subdomain cookies, secure cookies, CSRF, etc.)
163179
...(this.config.advanced ? {

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

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,23 @@
33
// Export IHttpServer from core
44
export * from '@objectstack/core';
55

6-
import {
7-
IHttpServer,
8-
RouteHandler,
9-
Middleware
6+
import {
7+
IHttpServer,
8+
RouteHandler,
9+
Middleware
1010
} from '@objectstack/core';
1111
import { Hono } from 'hono';
1212
import { serve } from '@hono/node-server';
1313
import { serveStatic } from '@hono/node-server/serve-static';
1414

15+
export interface HonoCorsOptions {
16+
enabled?: boolean;
17+
origins?: string | string[];
18+
methods?: string[];
19+
credentials?: boolean;
20+
maxAge?: number;
21+
}
22+
1523
/**
1624
* Hono Implementation of IHttpServer
1725
*/
@@ -137,7 +145,7 @@ export class HonoHttpServer implements IHttpServer {
137145
patch(path: string, handler: RouteHandler) {
138146
this.app.patch(path, this.wrap(handler));
139147
}
140-
148+
141149
use(pathOrHandler: string | Middleware, handler?: Middleware) {
142150
if (typeof pathOrHandler === 'string' && handler) {
143151
// Path based middleware
@@ -217,6 +225,4 @@ export class HonoHttpServer implements IHttpServer {
217225
this.server.close();
218226
}
219227
}
220-
221-
222228
}

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

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { Plugin, PluginContext, IHttpServer, IDataEngine } from '@objectstack/co
44
import {
55
RestServerConfig,
66
} from '@objectstack/spec/api';
7-
import { HonoHttpServer } from './adapter';
7+
import { HonoHttpServer, HonoCorsOptions } from './adapter';
8+
import { cors } from 'hono/cors';
89
import { serveStatic } from '@hono/node-server/serve-static';
910
import * as fs from 'fs';
1011
import * as path from 'path';
@@ -45,14 +46,22 @@ export interface HonoPluginOptions {
4546
* @default false
4647
*/
4748
spaFallback?: boolean;
49+
50+
/**
51+
* CORS configuration. Set to `false` to disable entirely.
52+
* Enabled by default with origin '*'.
53+
* Can also be controlled via environment variables:
54+
* CORS_ENABLED, CORS_ORIGIN, CORS_CREDENTIALS, CORS_MAX_AGE
55+
*/
56+
cors?: HonoCorsOptions | false;
4857
}
4958

5059
/**
5160
* Hono Server Plugin
52-
*
61+
*
5362
* Provides HTTP server capabilities using Hono framework.
5463
* Registers the IHttpServer service so other plugins can register routes.
55-
*
64+
*
5665
* Route registration is handled by plugins:
5766
* - `@objectstack/rest` → CRUD, metadata, discovery, UI, batch
5867
* - `createDispatcherPlugin()` → auth, graphql, analytics, packages, etc.
@@ -61,17 +70,17 @@ export class HonoServerPlugin implements Plugin {
6170
name = 'com.objectstack.server.hono';
6271
type = 'server';
6372
version = '0.9.0';
64-
73+
6574
// Constants
6675
private static readonly DEFAULT_ENDPOINT_PRIORITY = 100;
6776
private static readonly CORE_ENDPOINT_PRIORITY = 950;
6877
private static readonly DISCOVERY_ENDPOINT_PRIORITY = 900;
69-
78+
7079
private options: HonoPluginOptions;
7180
private server: HonoHttpServer;
7281

7382
constructor(options: HonoPluginOptions = {}) {
74-
this.options = {
83+
this.options = {
7584
port: 3000,
7685
registerStandardEndpoints: true,
7786
useApiRegistry: true,
@@ -86,17 +95,61 @@ export class HonoServerPlugin implements Plugin {
8695
* Init phase - Setup HTTP server and register as service
8796
*/
8897
init = async (ctx: PluginContext) => {
89-
ctx.logger.debug('Initializing Hono server plugin', {
98+
ctx.logger.debug('Initializing Hono server plugin', {
9099
port: this.options.port,
91-
staticRoot: this.options.staticRoot
100+
staticRoot: this.options.staticRoot
92101
});
93-
102+
94103
// Register HTTP server service as IHttpServer
95104
// Register as 'http.server' to match core requirements
96105
ctx.registerService('http.server', this.server);
97106
// Alias 'http-server' for backward compatibility
98107
ctx.registerService('http-server', this.server);
99108
ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' });
109+
110+
// ─── CORS Middleware ──────────────────────────────────────────────────
111+
// Enabled by default. Controlled via options.cors or environment variables.
112+
const corsDisabledByEnv = process.env.CORS_ENABLED === 'false';
113+
if (this.options.cors !== false && !corsDisabledByEnv) {
114+
const corsOpts = typeof this.options.cors === 'object' ? this.options.cors : {};
115+
const enabled = corsOpts.enabled ?? true;
116+
117+
if (enabled) {
118+
let configuredOrigin: string | string[];
119+
if (corsOpts.origins) {
120+
configuredOrigin = corsOpts.origins;
121+
} else if (process.env.CORS_ORIGIN) {
122+
const envOrigin = process.env.CORS_ORIGIN.trim();
123+
configuredOrigin = envOrigin.includes(',') ? envOrigin.split(',').map(s => s.trim()) : envOrigin;
124+
} else {
125+
configuredOrigin = '*';
126+
}
127+
128+
const credentials = corsOpts.credentials ?? (process.env.CORS_CREDENTIALS !== 'false');
129+
const maxAge = corsOpts.maxAge ?? (process.env.CORS_MAX_AGE ? parseInt(process.env.CORS_MAX_AGE, 10) : 86400);
130+
131+
// When credentials is true, browsers reject wildcard '*' for Access-Control-Allow-Origin.
132+
// Use a function to reflect the request's Origin header instead.
133+
let origin: string | string[] | ((origin: string) => string | undefined | null);
134+
if (credentials && configuredOrigin === '*') {
135+
origin = (requestOrigin: string) => requestOrigin || '*';
136+
} else {
137+
origin = configuredOrigin;
138+
}
139+
140+
const rawApp = this.server.getRawApp();
141+
rawApp.use('*', cors({
142+
origin: origin as any,
143+
allowMethods: corsOpts.methods || ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'],
144+
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
145+
exposeHeaders: [],
146+
credentials,
147+
maxAge,
148+
}));
149+
150+
ctx.logger.debug('CORS middleware enabled', { origin: configuredOrigin, credentials });
151+
}
152+
}
100153
}
101154

102155
/**
@@ -112,8 +165,8 @@ export class HonoServerPlugin implements Plugin {
112165
try {
113166
const rawKernel = ctx.getKernel() as any;
114167
if (rawKernel.plugins) {
115-
const loadedPlugins = rawKernel.plugins instanceof Map
116-
? Array.from(rawKernel.plugins.values())
168+
const loadedPlugins = rawKernel.plugins instanceof Map
169+
? Array.from(rawKernel.plugins.values())
117170
: Array.isArray(rawKernel.plugins) ? rawKernel.plugins : Object.values(rawKernel.plugins);
118171

119172
for (const plugin of (loadedPlugins as any[])) {
@@ -123,10 +176,10 @@ export class HonoServerPlugin implements Plugin {
123176
// Derive base route from name: @org/console -> console
124177
const slug = plugin.slug || plugin.name.split('/').pop();
125178
const baseRoute = `/${slug}`;
126-
127-
ctx.logger.debug(`Auto-mounting UI Plugin: ${plugin.name}`, {
128-
path: baseRoute,
129-
root: plugin.staticPath
179+
180+
ctx.logger.debug(`Auto-mounting UI Plugin: ${plugin.name}`, {
181+
path: baseRoute,
182+
root: plugin.staticPath
130183
});
131184

132185
mounts.push({
@@ -161,7 +214,7 @@ export class HonoServerPlugin implements Plugin {
161214

162215
if (mounts.length > 0) {
163216
const rawApp = this.server.getRawApp();
164-
217+
165218
for (const mount of mounts) {
166219
const mountRoot = path.resolve(process.cwd(), mount.root);
167220

@@ -173,22 +226,22 @@ export class HonoServerPlugin implements Plugin {
173226
const mountPath = mount.path || '/';
174227
const normalizedPath = mountPath.startsWith('/') ? mountPath : `/${mountPath}`;
175228
const routePattern = normalizedPath === '/' ? '/*' : `${normalizedPath.replace(/\/$/, '')}/*`;
176-
229+
177230
// Routes to register: both /mount and /mount/*
178231
const routes = normalizedPath === '/' ? [routePattern] : [normalizedPath, routePattern];
179232

180-
ctx.logger.debug('Mounting static files', {
181-
to: routes,
182-
from: mountRoot,
183-
rewrite: mount.rewrite,
184-
spa: mount.spa
233+
ctx.logger.debug('Mounting static files', {
234+
to: routes,
235+
from: mountRoot,
236+
rewrite: mount.rewrite,
237+
spa: mount.spa
185238
});
186239

187240
routes.forEach(route => {
188241
// 1. Serve Static Files
189242
rawApp.get(
190-
route,
191-
serveStatic({
243+
route,
244+
serveStatic({
192245
root: mount.root,
193246
rewriteRequestPath: (reqPath) => {
194247
if (mount.rewrite && normalizedPath !== '/') {
@@ -208,12 +261,12 @@ export class HonoServerPlugin implements Plugin {
208261
// Skip if API path check
209262
const config = this.options.restConfig || {};
210263
const basePath = config.api?.basePath || '/api';
211-
264+
212265
if (c.req.path.startsWith(basePath)) {
213266
return next();
214267
}
215268

216-
return serveStatic({
269+
return serveStatic({
217270
root: mount.root,
218271
rewriteRequestPath: () => 'index.html'
219272
})(c, next);
@@ -232,7 +285,7 @@ export class HonoServerPlugin implements Plugin {
232285

233286
const port = this.options.port ?? 3000;
234287
ctx.logger.debug('Starting HTTP server', { port });
235-
288+
236289
await this.server.listen(port);
237290

238291
const actualPort = this.server.getPort();

0 commit comments

Comments
 (0)