diff --git a/packages/browseros-agent/apps/server/.env.example b/packages/browseros-agent/apps/server/.env.example index 085366c93..95ac369ed 100644 --- a/packages/browseros-agent/apps/server/.env.example +++ b/packages/browseros-agent/apps/server/.env.example @@ -13,6 +13,8 @@ BROWSEROS_VERSION= BROWSEROS_INSTALL_ID= BROWSEROS_CLIENT_ID= +BROWSEROS_TRUSTED_ORIGINS= + # Graph service CODEGEN_SERVICE_URL= diff --git a/packages/browseros-agent/apps/server/src/api/middleware/require-trusted-origin.ts b/packages/browseros-agent/apps/server/src/api/middleware/require-trusted-origin.ts new file mode 100644 index 000000000..d66321eb2 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/api/middleware/require-trusted-origin.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { MiddlewareHandler } from 'hono' +import { isAllowedOrigin } from '../utils/cors' + +export function requireTrustedOrigin(): MiddlewareHandler { + return async (c, next) => { + const origin = c.req.header('Origin') + if (origin !== undefined && !isAllowedOrigin(origin)) { + return c.json( + { + error: { + name: 'ForbiddenOrigin', + message: 'Origin not allowed', + code: 'FORBIDDEN_ORIGIN', + statusCode: 403, + }, + }, + 403, + ) + } + return next() + } +} diff --git a/packages/browseros-agent/apps/server/src/api/server.ts b/packages/browseros-agent/apps/server/src/api/server.ts index fef5274a3..d50778376 100644 --- a/packages/browseros-agent/apps/server/src/api/server.ts +++ b/packages/browseros-agent/apps/server/src/api/server.ts @@ -20,6 +20,7 @@ import { initializeOAuth } from '../lib/clients/oauth' import { getDb } from '../lib/db' import { logger } from '../lib/logger' import { Sentry } from '../lib/sentry' +import { requireTrustedOrigin } from './middleware/require-trusted-origin' import { createChatRoutes } from './routes/chat' import { createCreditsRoutes } from './routes/credits' import { createHealthRoute } from './routes/health' @@ -103,6 +104,7 @@ export async function createHttpServer(config: HttpServerConfig) { const app = new Hono() .use('/*', cors(defaultCorsConfig)) + .use('/*', requireTrustedOrigin()) .route('/health', createHealthRoute({ browser })) .route( '/shutdown', diff --git a/packages/browseros-agent/apps/server/src/api/utils/cors.ts b/packages/browseros-agent/apps/server/src/api/utils/cors.ts index f5bc0364c..3d4a00a08 100644 --- a/packages/browseros-agent/apps/server/src/api/utils/cors.ts +++ b/packages/browseros-agent/apps/server/src/api/utils/cors.ts @@ -8,12 +8,40 @@ import type { cors } from 'hono/cors' type CorsOptions = Parameters[0] -/** - * Default CORS configuration for the HTTP server. - * Permissive since MCP endpoints are protected by localhost check. - */ +const STATIC_ALLOWED_ORIGINS = new Set([ + 'chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk', +]) + +let cachedAllowedOrigins: Set | null = null + +function buildAllowedOrigins(): Set { + const fromEnv = (process.env.BROWSEROS_TRUSTED_ORIGINS ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0) + return new Set([...STATIC_ALLOWED_ORIGINS, ...fromEnv]) +} + +function getAllowedOrigins(): Set { + if (!cachedAllowedOrigins) { + cachedAllowedOrigins = buildAllowedOrigins() + } + return cachedAllowedOrigins +} + +export function resetAllowedOriginsForTesting(): void { + cachedAllowedOrigins = null +} + +export function isAllowedOrigin(origin: string): boolean { + return getAllowedOrigins().has(origin) +} + export const defaultCorsConfig: CorsOptions = { - origin: (origin: string | undefined) => origin || '*', + origin: (origin: string | undefined) => { + if (origin && isAllowedOrigin(origin)) return origin + return null + }, allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization', 'Accept'], credentials: true, diff --git a/packages/browseros-agent/apps/server/tests/api/middleware/require-trusted-origin.test.ts b/packages/browseros-agent/apps/server/tests/api/middleware/require-trusted-origin.test.ts new file mode 100644 index 000000000..3b7fe4fdf --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/middleware/require-trusted-origin.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import { requireTrustedOrigin } from '../../../src/api/middleware/require-trusted-origin' +import { resetAllowedOriginsForTesting } from '../../../src/api/utils/cors' + +function buildApp() { + return new Hono() + .use('/*', requireTrustedOrigin()) + .get('/probe', (c) => c.json({ ok: true })) + .post('/probe', (c) => c.json({ ok: true, method: 'POST' })) +} + +describe('requireTrustedOrigin', () => { + const previousEnv = process.env.BROWSEROS_TRUSTED_ORIGINS + + beforeEach(() => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'chrome-extension://allowed' + resetAllowedOriginsForTesting() + }) + afterEach(() => { + process.env.BROWSEROS_TRUSTED_ORIGINS = previousEnv + resetAllowedOriginsForTesting() + }) + + it('passes when no Origin header is set', async () => { + const res = await buildApp().request('/probe') + expect(res.status).toBe(200) + }) + + it('passes when Origin matches the allowlist', async () => { + const res = await buildApp().request('/probe', { + headers: { Origin: 'chrome-extension://allowed' }, + }) + expect(res.status).toBe(200) + }) + + it('rejects with 403 when Origin is unknown', async () => { + const res = await buildApp().request('/probe', { + headers: { Origin: 'https://example.com' }, + }) + expect(res.status).toBe(403) + const body = (await res.json()) as { error?: { code?: string } } + expect(body.error?.code).toBe('FORBIDDEN_ORIGIN') + }) + + it('rejects with 403 when Origin is the literal "null"', async () => { + const res = await buildApp().request('/probe', { + headers: { Origin: 'null' }, + }) + expect(res.status).toBe(403) + }) + + it('rejects POST with disallowed Origin without invoking the route handler', async () => { + const app = new Hono() + .use('/*', requireTrustedOrigin()) + .post('/probe', () => { + throw new Error('handler must not run') + }) + + const res = await app.request('/probe', { + method: 'POST', + headers: { Origin: 'https://example.com' }, + }) + expect(res.status).toBe(403) + }) + + it('rejects port mismatches even when host matches', async () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'http://localhost:5173' + resetAllowedOriginsForTesting() + const res = await buildApp().request('/probe', { + headers: { Origin: 'http://localhost:5174' }, + }) + expect(res.status).toBe(403) + }) + + it('treats the allowlist as case-sensitive', async () => { + const res = await buildApp().request('/probe', { + headers: { Origin: 'CHROME-EXTENSION://allowed' }, + }) + expect(res.status).toBe(403) + }) +}) diff --git a/packages/browseros-agent/apps/server/tests/api/utils/cors.test.ts b/packages/browseros-agent/apps/server/tests/api/utils/cors.test.ts new file mode 100644 index 000000000..800c14530 --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/utils/cors.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + isAllowedOrigin, + resetAllowedOriginsForTesting, +} from '../../../src/api/utils/cors' + +describe('isAllowedOrigin', () => { + const previousEnv = process.env.BROWSEROS_TRUSTED_ORIGINS + + beforeEach(() => { + resetAllowedOriginsForTesting() + }) + afterEach(() => { + process.env.BROWSEROS_TRUSTED_ORIGINS = previousEnv + resetAllowedOriginsForTesting() + }) + + it('accepts the pinned published extension origin even when env is empty', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = '' + expect( + isAllowedOrigin('chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk'), + ).toBe(true) + }) + + it('rejects unknown origins when env is empty', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = '' + expect(isAllowedOrigin('https://example.com')).toBe(false) + expect(isAllowedOrigin('chrome-extension://someotherid')).toBe(false) + expect(isAllowedOrigin('null')).toBe(false) + }) + + it('accepts a single origin from env', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'chrome-extension://abcdef' + expect(isAllowedOrigin('chrome-extension://abcdef')).toBe(true) + expect(isAllowedOrigin('chrome-extension://other')).toBe(false) + }) + + it('accepts multiple comma-separated origins and trims whitespace', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = + ' chrome-extension://abc , http://localhost:5173 ' + expect(isAllowedOrigin('chrome-extension://abc')).toBe(true) + expect(isAllowedOrigin('http://localhost:5173')).toBe(true) + expect(isAllowedOrigin('http://localhost:5174')).toBe(false) + }) + + it('is case-sensitive on origin match', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'chrome-extension://abc' + expect(isAllowedOrigin('CHROME-EXTENSION://abc')).toBe(false) + }) + + it('treats port as part of the origin', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'http://localhost:5173' + expect(isAllowedOrigin('http://localhost:5173')).toBe(true) + expect(isAllowedOrigin('http://localhost:5174')).toBe(false) + expect(isAllowedOrigin('http://localhost')).toBe(false) + }) + + it('rejects the literal string "null" unless explicitly allowlisted', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'chrome-extension://abc' + expect(isAllowedOrigin('null')).toBe(false) + }) + + it('drops empty entries between commas', () => { + process.env.BROWSEROS_TRUSTED_ORIGINS = 'chrome-extension://abc,,, ,' + expect(isAllowedOrigin('chrome-extension://abc')).toBe(true) + expect(isAllowedOrigin('')).toBe(false) + }) +})