Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');

Expand All @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/utils/corsConfig.ts
Original file line number Diff line number Diff line change
@@ -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<typeof cors> {
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 });
}
149 changes: 149 additions & 0 deletions tests/unit/utils/corsConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof buildCorsMiddleware>,
origin: string,
): Promise<Response> {
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();
});
});
});
Loading