From 4ccc3edcbbfeb5d148c9ff505f9cad26c80f441b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 27 Mar 2026 14:14:23 -0400 Subject: [PATCH] fix(websearch): restrict dashboard secrets routes --- src/web-server/routes/websearch-routes.ts | 32 ++++- .../unit/web-server/websearch-routes.test.ts | 109 +++++++++++++++++- 2 files changed, 136 insertions(+), 5 deletions(-) diff --git a/src/web-server/routes/websearch-routes.ts b/src/web-server/routes/websearch-routes.ts index ebcaf489..7bfae45f 100644 --- a/src/web-server/routes/websearch-routes.ts +++ b/src/web-server/routes/websearch-routes.ts @@ -11,8 +11,12 @@ import { getWebSearchApiKeyStates, type WebSearchApiKeyProviderId, } from '../../utils/websearch/provider-secrets'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); +const WEBSEARCH_LOCAL_ACCESS_ERROR = + 'WebSearch endpoints require localhost access when dashboard auth is disabled.'; +const WEBSEARCH_API_KEY_PROVIDER_IDS = ['exa', 'tavily', 'brave'] as const; type WebSearchApiKeyUpdates = Partial>; @@ -20,6 +24,12 @@ interface WebSearchDashboardPayload extends Partial { apiKeys?: WebSearchApiKeyUpdates; } +router.use((req: Request, res: Response, next) => { + if (requireLocalAccessWhenAuthDisabled(req, res, WEBSEARCH_LOCAL_ACCESS_ERROR)) { + next(); + } +}); + /** * GET /api/websearch - Get WebSearch configuration * Returns: normalized WebSearch configuration @@ -41,6 +51,16 @@ router.get('/', (_req: Request, res: Response): void => { * Body: WebSearchConfig fields (enabled, providers) */ router.put('/', (req: Request, res: Response): void => { + if ( + req.body === null || + req.body === undefined || + typeof req.body !== 'object' || + Array.isArray(req.body) + ) { + res.status(400).json({ error: 'Invalid request body. Must be an object.' }); + return; + } + const { enabled, providers, apiKeys } = req.body as WebSearchDashboardPayload; // Validate enabled @@ -50,19 +70,25 @@ router.put('/', (req: Request, res: Response): void => { } // Validate providers if specified - if (providers !== undefined && typeof providers !== 'object') { + if ( + providers !== undefined && + (providers === null || Array.isArray(providers) || typeof providers !== 'object') + ) { res.status(400).json({ error: 'Invalid value for providers. Must be an object.' }); return; } - if (apiKeys !== undefined && typeof apiKeys !== 'object') { + if ( + apiKeys !== undefined && + (apiKeys === null || Array.isArray(apiKeys) || typeof apiKeys !== 'object') + ) { res.status(400).json({ error: 'Invalid value for apiKeys. Must be an object.' }); return; } if (apiKeys) { for (const [providerId, value] of Object.entries(apiKeys)) { - if (!['exa', 'tavily', 'brave'].includes(providerId)) { + if (!WEBSEARCH_API_KEY_PROVIDER_IDS.includes(providerId as WebSearchApiKeyProviderId)) { res.status(400).json({ error: `Unsupported WebSearch provider: ${providerId}` }); return; } diff --git a/tests/unit/web-server/websearch-routes.test.ts b/tests/unit/web-server/websearch-routes.test.ts index b7a946f7..42f9b08b 100644 --- a/tests/unit/web-server/websearch-routes.test.ts +++ b/tests/unit/web-server/websearch-routes.test.ts @@ -4,7 +4,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; -import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { + loadOrCreateUnifiedConfig, + mutateUnifiedConfig, +} from '../../../src/config/unified-config-loader'; import websearchRoutes from '../../../src/web-server/routes/websearch-routes'; const WEBSEARCH_ENV_KEYS = [ @@ -21,11 +24,20 @@ describe('websearch routes', () => { let baseUrl = ''; let tempHome: string; let originalCcsHome: string | undefined; + let originalDashboardAuthEnabled: string | undefined; let originalEnvValues: Record<(typeof WEBSEARCH_ENV_KEYS)[number], string | undefined>; + let forcedRemoteAddress = '127.0.0.1'; beforeAll(async () => { const app = express(); app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); app.use('/api/websearch', websearchRoutes); await new Promise((resolve, reject) => { @@ -54,7 +66,10 @@ describe('websearch routes', () => { beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-routes-test-')); originalCcsHome = process.env.CCS_HOME; + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; process.env.CCS_HOME = tempHome; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; originalEnvValues = WEBSEARCH_ENV_KEYS.reduce( (acc, key) => { @@ -73,6 +88,12 @@ describe('websearch routes', () => { delete process.env.CCS_HOME; } + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } + for (const key of WEBSEARCH_ENV_KEYS) { const value = originalEnvValues[key]; if (value !== undefined) { @@ -87,6 +108,41 @@ describe('websearch routes', () => { } }); + it('blocks remote access when dashboard auth is disabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + + const getResponse = await fetch(`${baseUrl}/api/websearch`); + expect(getResponse.status).toBe(403); + expect(await getResponse.json()).toEqual({ + error: 'WebSearch endpoints require localhost access when dashboard auth is disabled.', + }); + + const putResponse = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiKeys: { + exa: 'exa-secret-abcdefgh', + }, + }), + }); + expect(putResponse.status).toBe(403); + expect(await putResponse.json()).toEqual({ + error: 'WebSearch endpoints require localhost access when dashboard auth is disabled.', + }); + + const config = loadOrCreateUnifiedConfig(); + expect(config.global_env?.env.EXA_API_KEY).toBeUndefined(); + }); + + it('allows remote access when dashboard auth is enabled', async () => { + forcedRemoteAddress = '10.10.0.24'; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + + const response = await fetch(`${baseUrl}/api/websearch`); + expect(response.status).toBe(200); + }); + it('returns masked API key state from dashboard-managed global env', async () => { mutateUnifiedConfig((config) => { config.websearch = { @@ -128,7 +184,9 @@ describe('websearch routes', () => { expect(statusPayload.readiness).toMatchObject({ status: 'ready', }); - expect(statusPayload.providers.find((provider: { id: string }) => provider.id === 'exa')).toMatchObject({ + expect( + statusPayload.providers.find((provider: { id: string }) => provider.id === 'exa') + ).toMatchObject({ available: true, detail: 'API key detected (7 results)', }); @@ -218,4 +276,51 @@ describe('websearch routes', () => { const config = loadOrCreateUnifiedConfig(); expect(config.global_env?.env.EXA_API_KEY).toBe('exa-secret-12345678'); }); + + it('rejects non-object request bodies', async () => { + const response = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: '[]', + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid request body. Must be an object.', + }); + }); + + it('rejects unsupported API key providers', async () => { + const response = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiKeys: { + invalid: 'secret', + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Unsupported WebSearch provider: invalid', + }); + }); + + it('rejects non-string API key values', async () => { + const response = await fetch(`${baseUrl}/api/websearch`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + apiKeys: { + exa: 123, + }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: 'Invalid value for exa API key', + }); + }); });