Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }
//
Expand Down Expand Up @@ -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' });
Expand Down
102 changes: 102 additions & 0 deletions tests/unit/evaluate-auth.test.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
});