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(); + } + }); +});