diff --git a/src/dashboard.ts b/src/dashboard.ts index 11aa6445..e3d781c7 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -19,7 +19,6 @@ import { serveStatic } from '@hono/node-server/serve-static'; import { trpcServer } from '@hono/trpc-server'; import { Hono } from 'hono'; import { getCookie } from 'hono/cookie'; -import { cors } from 'hono/cors'; import { logger as honoLogger } from 'hono/logger'; import { initPrompts } from './agents/prompts/index.js'; import { SESSION_COOKIE_NAME } from './api/auth/cookie.js'; @@ -30,6 +29,7 @@ import { computeEffectiveOrgId } from './api/context.js'; import { appRouter } from './api/router.js'; import { registerBuiltInEngines } from './backends/bootstrap.js'; import { captureException, flush, setTag } from './sentry.js'; +import { buildCorsMiddleware } from './utils/corsConfig.js'; setTag('role', 'dashboard'); @@ -40,12 +40,13 @@ registerBuiltInEngines(); const app = new Hono(); // Middleware -const corsOrigin = process.env.CORS_ORIGIN; -const corsOrigins = corsOrigin - ?.split(',') - .map((o) => o.trim()) - .filter(Boolean); -app.use('*', corsOrigins?.length ? cors({ origin: corsOrigins, credentials: true }) : cors()); +app.use( + '*', + buildCorsMiddleware({ + corsOriginEnv: process.env.CORS_ORIGIN, + isProduction: process.env.NODE_ENV === 'production', + }), +); app.use('*', honoLogger()); // Health check diff --git a/src/utils/corsConfig.ts b/src/utils/corsConfig.ts new file mode 100644 index 00000000..a646aeb4 --- /dev/null +++ b/src/utils/corsConfig.ts @@ -0,0 +1,61 @@ +/** + * CORS configuration helper for the dashboard server. + * + * Selects the appropriate CORS middleware options based on environment: + * - When CORS_ORIGIN is set: allow only those origins (comma-separated) + * - When unset in production: warn and allow no origins (restrictive default) + * - When unset outside production: default to localhost:5173 (dev convenience) + */ + +import { cors } from 'hono/cors'; + +export interface CorsConfigOptions { + corsOriginEnv: string | undefined; + isProduction: boolean; + warn?: (message: string) => void; +} + +export interface CorsConfig { + /** Resolved allowed origins for use in CORS middleware */ + origins: string | string[]; + /** Whether credentials are included in the CORS config */ + credentials: true; +} + +/** + * Resolves CORS middleware configuration based on environment. + * + * Returns a ready-to-use Hono `cors()` middleware configured for the current + * environment: + * - If `corsOriginEnv` is set (comma-separated), only those origins are allowed. + * - If `corsOriginEnv` is unset in production, a warning is logged and an empty + * origin list is used (blocks all cross-origin requests). + * - If `corsOriginEnv` is unset outside production, `http://localhost:5173` is + * used as a dev-friendly default. + */ +export function buildCorsMiddleware({ + corsOriginEnv, + isProduction, + warn = console.warn, +}: CorsConfigOptions): ReturnType { + const origins = corsOriginEnv + ?.split(',') + .map((o) => o.trim()) + .filter(Boolean); + + if (origins?.length) { + return cors({ origin: origins, credentials: true }); + } + + if (isProduction) { + warn( + '[Dashboard] WARNING: CORS_ORIGIN is not set in production. ' + + 'Using restrictive default (no origins allowed). ' + + 'Set CORS_ORIGIN to your frontend URL (e.g., https://dashboard.example.com).', + ); + return cors({ origin: [], credentials: true }); + } + + // Development default + return cors({ origin: 'http://localhost:5173', credentials: true }); +} diff --git a/tests/unit/utils/corsConfig.test.ts b/tests/unit/utils/corsConfig.test.ts new file mode 100644 index 00000000..8147d400 --- /dev/null +++ b/tests/unit/utils/corsConfig.test.ts @@ -0,0 +1,149 @@ +import { Hono } from 'hono'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildCorsMiddleware } from '../../../src/utils/corsConfig.js'; + +/** + * Helper: sends a cross-origin request to a minimal Hono app with the + * given CORS middleware and returns the `Access-Control-Allow-Origin` header. + */ +async function fetchWithOrigin( + middleware: ReturnType, + origin: string, +): Promise { + const app = new Hono(); + app.use('*', middleware); + app.get('/test', (c) => c.text('ok')); + + return app.request('/test', { + method: 'GET', + headers: { Origin: origin }, + }); +} + +describe('buildCorsMiddleware', () => { + describe('when CORS_ORIGIN is set', () => { + it('allows a single configured origin', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: 'https://dashboard.example.com', + isProduction: true, + }); + + const res = await fetchWithOrigin(middleware, 'https://dashboard.example.com'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://dashboard.example.com'); + }); + + it('allows the first of multiple comma-separated origins when matched', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: 'https://app.example.com,https://dev.example.com', + isProduction: true, + }); + + const res = await fetchWithOrigin(middleware, 'https://dev.example.com'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://dev.example.com'); + }); + + it('does not allow an origin not in the list', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: 'https://dashboard.example.com', + isProduction: true, + }); + + const res = await fetchWithOrigin(middleware, 'https://evil.example.com'); + // Hono cors returns null (no header) when origin is not allowed + expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); + }); + + it('trims whitespace around comma-separated origins', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: ' https://app.example.com , https://dev.example.com ', + isProduction: false, + }); + + const res = await fetchWithOrigin(middleware, 'https://app.example.com'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://app.example.com'); + }); + + it('does not emit a production warning when CORS_ORIGIN is set', () => { + const warn = vi.fn(); + buildCorsMiddleware({ + corsOriginEnv: 'https://dashboard.example.com', + isProduction: true, + warn, + }); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('when CORS_ORIGIN is not set AND NODE_ENV=production', () => { + it('logs a warning at startup', () => { + const warn = vi.fn(); + buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: true, + warn, + }); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('CORS_ORIGIN is not set in production'), + ); + }); + + it('blocks all cross-origin requests (empty origin list)', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: true, + warn: vi.fn(), + }); + + const res = await fetchWithOrigin(middleware, 'https://any-origin.example.com'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); + }); + + it('also blocks localhost when in production without CORS_ORIGIN', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: true, + warn: vi.fn(), + }); + + const res = await fetchWithOrigin(middleware, 'http://localhost:5173'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); + }); + }); + + describe('when CORS_ORIGIN is not set AND NODE_ENV!=production', () => { + it('defaults to localhost:5173 with credentials', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: false, + }); + + const res = await fetchWithOrigin(middleware, 'http://localhost:5173'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:5173'); + }); + + it('does not allow other origins when defaulting to localhost:5173', async () => { + const middleware = buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: false, + }); + + const res = await fetchWithOrigin(middleware, 'https://evil.example.com'); + expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull(); + }); + + it('does not log a warning in development', () => { + const warn = vi.fn(); + buildCorsMiddleware({ + corsOriginEnv: undefined, + isProduction: false, + warn, + }); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +});