fix(server): tighten CORS allowlist for the agent server (#966)

* fix(server): tighten CORS allowlist for the agent server

Replace the permissive `origin || '*'` reflection in
`defaultCorsConfig` with an explicit allowlist composed of:

- a static list (empty by default)
- comma-separated origins from `BROWSEROS_TRUSTED_ORIGINS`

Add a small `requireTrustedOrigin` middleware that actively
rejects (403) any request whose `Origin` header is present and
not in the allowlist. The middleware is permissive when the
`Origin` header is absent — CLI tools, internal Node clients,
and some service-worker fetches legitimately omit it; the
threat model only covers cross-origin browser fetches, which
always carry `Origin` (it's on the Forbidden Header List, so
JS cannot suppress it).

Mount the middleware globally in `createHttpServer` after the
existing `cors()` layer. Document the new env var in
`.env.example`.

Tests cover allowlist parsing (empty, single, multi, trims,
case sensitivity, port match) and middleware behaviour
(missing Origin allowed, allowlisted Origin allowed, unknown
Origin rejected, "null" rejected, port mismatch rejected,
disallowed Origin doesn't reach the handler).

* fix(server): include published extension origin in default allowlist

Pin the published BrowserOS extension origin in the static
allowlist so the default install accepts the legitimate
extension without requiring `BROWSEROS_TRUSTED_ORIGINS` to be
populated. Additional origins (dev / alpha) keep working
through the env override.

* chore(server): trim .env.example comments

* chore(server): drop redundant comments from cors helpers
This commit is contained in:
Dani Akash
2026-05-08 11:22:54 +05:30
committed by GitHub
parent 4a3b9ff294
commit dde403962f
6 changed files with 225 additions and 5 deletions

View File

@@ -13,6 +13,8 @@ BROWSEROS_VERSION=
BROWSEROS_INSTALL_ID=
BROWSEROS_CLIENT_ID=
BROWSEROS_TRUSTED_ORIGINS=
# Graph service
CODEGEN_SERVICE_URL=

View File

@@ -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()
}
}

View File

@@ -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<Env>()
.use('/*', cors(defaultCorsConfig))
.use('/*', requireTrustedOrigin())
.route('/health', createHealthRoute({ browser }))
.route(
'/shutdown',

View File

@@ -8,12 +8,40 @@ import type { cors } from 'hono/cors'
type CorsOptions = Parameters<typeof cors>[0]
/**
* Default CORS configuration for the HTTP server.
* Permissive since MCP endpoints are protected by localhost check.
*/
const STATIC_ALLOWED_ORIGINS = new Set<string>([
'chrome-extension://bflpfmnmnokmjhmgnolecpppdbdophmk',
])
let cachedAllowedOrigins: Set<string> | null = null
function buildAllowedOrigins(): Set<string> {
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<string> {
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,

View File

@@ -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)
})
})

View File

@@ -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)
})
})