From cdc02e98731fbcb8007b1eaf2e1d4468a19d0be2 Mon Sep 17 00:00:00 2001 From: Sebastion Date: Sun, 5 Apr 2026 21:03:11 +0100 Subject: [PATCH] fix: require API key authentication on /tabs/:tabId/evaluate endpoint The evaluate endpoint allows arbitrary JavaScript execution in the browser page context. Previously it had no authentication, meaning any network client could call it to execute JS in authenticated sessions (imported cookies), steal session tokens, or exfiltrate page data. This adds the same CAMOFOX_API_KEY / Bearer token check used by the cookie import endpoint, extracted into a reusable requireApiKey middleware. When CAMOFOX_API_KEY is set, a valid Bearer token is required. When unset, only loopback requests in non-production environments are allowed. CWE-94: Improper Control of Generation of Code (Code Injection) --- server.js | 23 ++++++- tests/unit/evaluate-auth.test.js | 102 +++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/unit/evaluate-auth.test.js diff --git a/server.js b/server.js index 4083899..c86e2bf 100644 --- a/server.js +++ b/server.js @@ -221,6 +221,27 @@ function validateUrl(url) { // isLoopbackAddress -- now imported from lib/auth.js (see top of file) +// Middleware: require CAMOFOX_API_KEY via Bearer token. +// When the key is not set, allow only loopback requests in non-production environments. +function requireApiKey(req, res, next) { + if (CONFIG.apiKey) { + const auth = String(req.headers['authorization'] || ''); + const match = auth.match(/^Bearer\s+(.+)$/i); + if (!match || !timingSafeCompare(match[1], CONFIG.apiKey)) { + return res.status(403).json({ error: 'Forbidden' }); + } + } else { + const remoteAddress = req.socket?.remoteAddress || ''; + const allowUnauthedLocal = CONFIG.nodeEnv !== 'production' && isLoopbackAddress(remoteAddress); + if (!allowUnauthedLocal) { + return res.status(403).json({ + error: 'This endpoint requires CAMOFOX_API_KEY except for loopback requests in non-production environments.', + }); + } + } + next(); +} + // Import cookies into a user's browser context (Playwright cookies format) // POST /sessions/:userId/cookies { cookies: Cookie[] } // @@ -4337,7 +4358,7 @@ app.get('/tabs/:tabId/stats', async (req, res) => { * schema: * $ref: '#/components/schemas/Error' */ -app.post('/tabs/:tabId/evaluate', express.json({ limit: '1mb' }), async (req, res) => { +app.post('/tabs/:tabId/evaluate', requireApiKey, express.json({ limit: '1mb' }), async (req, res) => { try { const { userId, expression } = req.body; if (!userId) return res.status(400).json({ error: 'userId is required' }); diff --git a/tests/unit/evaluate-auth.test.js b/tests/unit/evaluate-auth.test.js new file mode 100644 index 0000000..fa2e289 --- /dev/null +++ b/tests/unit/evaluate-auth.test.js @@ -0,0 +1,102 @@ +import { startServer, stopServer, getServerUrl } from '../helpers/startServer.js'; +import { startTestSite, stopTestSite, getTestSiteUrl } from '../helpers/testSite.js'; +import { createClient } from '../helpers/client.js'; + +/** + * Tests that the /tabs/:tabId/evaluate endpoint requires API key authentication + * when CAMOFOX_API_KEY is configured, matching the security model used by the + * cookie import endpoint. + * + * CVE: CWE-94 — Arbitrary JS execution without authentication + */ +describe('Evaluate endpoint authentication', () => { + const TEST_API_KEY = 'test-secret-key-for-evaluate-auth'; + let serverUrl; + let testSiteUrl; + + beforeAll(async () => { + await startServer(0, { CAMOFOX_API_KEY: TEST_API_KEY }); + serverUrl = getServerUrl(); + const testPort = await startTestSite(); + testSiteUrl = getTestSiteUrl(); + }, 120000); + + afterAll(async () => { + await stopTestSite(); + await stopServer(); + }, 30000); + + test('rejects evaluate without Bearer token when API key is configured', async () => { + const client = createClient(serverUrl); + try { + const { tabId } = await client.createTab(`${testSiteUrl}/pageA`); + + // Call evaluate directly without auth header + const res = await fetch(`${serverUrl}/tabs/${tabId}/evaluate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: client.userId, + expression: 'document.title', + }), + }); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBe('Forbidden'); + } finally { + await client.cleanup(); + } + }); + + test('rejects evaluate with wrong Bearer token', async () => { + const client = createClient(serverUrl); + try { + const { tabId } = await client.createTab(`${testSiteUrl}/pageA`); + + const res = await fetch(`${serverUrl}/tabs/${tabId}/evaluate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer wrong-key', + }, + body: JSON.stringify({ + userId: client.userId, + expression: 'document.title', + }), + }); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBe('Forbidden'); + } finally { + await client.cleanup(); + } + }); + + test('allows evaluate with correct Bearer token', async () => { + const client = createClient(serverUrl); + try { + const { tabId } = await client.createTab(`${testSiteUrl}/pageA`); + + const res = await fetch(`${serverUrl}/tabs/${tabId}/evaluate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${TEST_API_KEY}`, + }, + body: JSON.stringify({ + userId: client.userId, + expression: 'document.title', + }), + }); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.ok).toBe(true); + expect(data.result).toBe('Page A'); + } finally { + await client.cleanup(); + } + }); +});