fix: harden local dashboard and data boundaries (#2585)

* fix: harden local data boundaries

Bind the capabilities dashboard exclusively to loopback and reject untrusted Host and Origin values. Constrain project-configured agent data paths to the Cursor data root, and harden lifecycle repair/uninstall operations against state-file traversal, symlink swaps, unsafe sources, and forged install-state destinations.\n\nCloses #2506

* fix: eliminate repair source read race

Read source bytes and mode from one no-follow file descriptor so a path replacement cannot mix metadata from one inode with content from another. Add a regression that rejects separate path-based source metadata lookup.

* fix: close dashboard hardening review gaps
This commit is contained in:
Affaan Mustafa 2026-07-27 11:11:29 -07:00 committed by GitHub
parent 4da6deac18
commit 382060905e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2320 additions and 193 deletions

View file

@ -7,12 +7,15 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const net = require('net');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js');
let testRoot;
let testPassed = 0;
let testFailed = 0;
const asyncTests = [];
const REQUEST_TIMEOUT_MS = 5000;
function test(name, fn) {
try {
@ -28,6 +31,10 @@ function test(name, fn) {
}
}
function asyncTest(name, fn) {
asyncTests.push({ name, fn });
}
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
@ -65,6 +72,121 @@ function writeFile(rootDir, relativePath, content) {
fs.writeFileSync(targetPath, content);
}
function requestDashboard(port, options = {}) {
return new Promise((resolve, reject) => {
let settled = false;
let request;
const settle = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback(value);
};
const timeout = setTimeout(() => {
const error = new Error(
`Dashboard request timed out after ${REQUEST_TIMEOUT_MS}ms`
);
if (request) request.destroy();
settle(reject, error);
}, REQUEST_TIMEOUT_MS);
request = http.request({
host: '127.0.0.1',
port,
method: options.method || 'GET',
path: options.path || '/',
headers: options.headers || {},
setHost: options.setHost !== false,
}, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', error => settle(reject, error));
response.on('end', () => {
settle(resolve, {
body,
headers: response.headers,
statusCode: response.statusCode,
});
});
});
request.on('error', error => settle(reject, error));
request.end();
});
}
function requestDashboardWithoutHost(port) {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
let raw = '';
let settled = false;
const settle = (callback, value) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback(value);
};
const timeout = setTimeout(() => {
const error = new Error(
`Host-less request timed out after ${REQUEST_TIMEOUT_MS}ms`
);
socket.destroy();
settle(reject, error);
}, REQUEST_TIMEOUT_MS);
socket.setEncoding('utf8');
socket.on('connect', () => {
socket.write('GET / HTTP/1.0\r\n\r\n');
});
socket.on('data', (chunk) => {
raw += chunk;
});
socket.on('end', () => {
const [head, body = ''] = raw.split('\r\n\r\n');
const lines = head.split('\r\n');
const statusCode = Number.parseInt(lines[0].split(' ')[1], 10);
const headers = {};
for (const line of lines.slice(1)) {
const separator = line.indexOf(':');
if (separator < 1) continue;
headers[line.slice(0, separator).toLowerCase()] = line.slice(separator + 1).trim();
}
settle(resolve, { body, headers, statusCode });
});
socket.on('error', error => settle(reject, error));
socket.on('close', hadError => {
if (!hadError && !settled) {
settle(reject, new Error('Host-less request closed before completion'));
}
});
});
}
async function withDashboardServer(fn, serverOptions = {}) {
const { createDashboardServer } = require(SCRIPT);
const testServer = createDashboardServer({
host: '127.0.0.1',
...serverOptions,
});
await new Promise((resolve, reject) => {
testServer.once('error', reject);
testServer.listen(0, '127.0.0.1', () => {
testServer.off('error', reject);
resolve();
});
});
try {
await fn(testServer.address().port);
} finally {
await new Promise((resolve, reject) => {
testServer.close(error => (error ? reject(error) : resolve()));
});
}
}
// ===================== parsePort =====================
test('parsePort returns 3456 for undefined', () => {
@ -721,49 +843,228 @@ test('renderHTML includes the dashboard title and footer', () => {
// ===================== Server / HTTP =====================
test('server returns HTML on GET /', (done) => {
const { server } = require(SCRIPT);
// Server may or may not be listening — we start it on a random port
const testServer = http.createServer(server._events.request);
testServer.listen(0, () => {
const port = testServer.address().port;
http.get(`http://localhost:${port}/`, (res) => {
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'text/html; charset=utf-8');
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
assert.ok(body.includes('<!DOCTYPE html>'));
assert.ok(body.includes('ECC Capabilities'));
testServer.close();
done();
});
});
test('resolveDashboardHost defaults to IPv4 loopback', () => {
const { resolveDashboardHost } = require(SCRIPT);
assert.strictEqual(resolveDashboardHost({}), '127.0.0.1');
assert.strictEqual(resolveDashboardHost({ ECC_DASHBOARD_HOST: '' }), '127.0.0.1');
});
test('resolveDashboardHost accepts only normalized loopback hosts', () => {
const { resolveDashboardHost } = require(SCRIPT);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: ' LOCALHOST ' }),
'localhost'
);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: '::1' }),
'::1'
);
assert.strictEqual(
resolveDashboardHost({ ECC_DASHBOARD_HOST: '[::1]' }),
'::1'
);
});
test('resolveDashboardHost rejects wildcard, LAN, and arbitrary hosts', () => {
const { resolveDashboardHost } = require(SCRIPT);
for (const host of ['0.0.0.0', '::', '192.168.1.10', 'dashboard.internal', '127.0.0.1:3456']) {
assert.throws(
() => resolveDashboardHost({ ECC_DASHBOARD_HOST: host }),
/ECC_DASHBOARD_HOST must be loopback-only/
);
}
});
test('listenDashboardServer always passes an explicit loopback host to listen', () => {
const { listenDashboardServer } = require(SCRIPT);
const calls = [];
const fakeServer = {
listen(...args) {
calls.push(args);
return this;
},
};
const onListening = () => {};
assert.strictEqual(
listenDashboardServer(fakeServer, {
host: '127.0.0.1',
onListening,
port: 3456,
}),
fakeServer
);
assert.deepStrictEqual(calls, [[3456, '127.0.0.1', onListening]]);
assert.throws(
() => listenDashboardServer(fakeServer, { host: '0.0.0.0', port: 3456 }),
/ECC_DASHBOARD_HOST must be loopback-only/
);
assert.strictEqual(calls.length, 1);
});
asyncTest('server returns no-store HTML on GET /', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port);
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'text/html; charset=utf-8');
assert.strictEqual(response.headers['cache-control'], 'no-store');
assert.ok(response.body.includes('<!DOCTYPE html>'));
assert.ok(response.body.includes('ECC Capabilities'));
});
});
test('server returns JSON on GET /api/data', (done) => {
const { server } = require(SCRIPT);
const testServer = http.createServer(server._events.request);
testServer.listen(0, () => {
const port = testServer.address().port;
http.get(`http://localhost:${port}/api/data`, (res) => {
assert.strictEqual(res.statusCode, 200);
assert.strictEqual(res.headers['content-type'], 'application/json');
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
const parsed = JSON.parse(body);
assert.ok(Array.isArray(parsed.agents));
assert.ok(Array.isArray(parsed.skills));
assert.ok(Array.isArray(parsed.commands));
assert.ok(Array.isArray(parsed.rules));
assert.ok(Array.isArray(parsed.mcps));
assert.ok(Array.isArray(parsed.hooks));
testServer.close();
done();
});
asyncTest('server returns no-store JSON on GET /api/data', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, { path: '/api/data' });
assert.strictEqual(response.statusCode, 200);
assert.strictEqual(response.headers['content-type'], 'application/json');
assert.strictEqual(response.headers['cache-control'], 'no-store');
const parsed = JSON.parse(response.body);
assert.ok(Array.isArray(parsed.agents));
assert.ok(Array.isArray(parsed.skills));
assert.ok(Array.isArray(parsed.commands));
assert.ok(Array.isArray(parsed.rules));
assert.ok(Array.isArray(parsed.mcps));
assert.ok(Array.isArray(parsed.hooks));
});
});
asyncTest('server returns a generic no-store 500 for data failures and remains usable', async () => {
let loadCount = 0;
const loggedErrors = [];
const emptyData = {
agents: [],
skills: [],
commands: [],
rules: [],
mcps: [],
hooks: [],
};
await withDashboardServer(async (port) => {
const failedResponse = await requestDashboard(port, { path: '/api/data' });
assert.strictEqual(failedResponse.statusCode, 500);
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
assert.deepStrictEqual(JSON.parse(failedResponse.body), {
error: 'Internal server error',
});
assert.ok(!failedResponse.body.includes('sensitive loader detail'));
const followUpResponse = await requestDashboard(port, { path: '/api/data' });
assert.strictEqual(followUpResponse.statusCode, 200);
assert.deepStrictEqual(JSON.parse(followUpResponse.body), emptyData);
assert.strictEqual(loggedErrors.length, 1);
assert.strictEqual(loggedErrors[0].error.message, 'sensitive loader detail');
}, {
loadData: () => {
loadCount++;
if (loadCount === 1) {
throw new Error('sensitive loader detail');
}
return emptyData;
},
reportError: (message, error) => {
loggedErrors.push({ error, message });
},
});
});
asyncTest('server returns a generic no-store 500 for render failures and remains usable', async () => {
const { renderHTML } = require(SCRIPT);
let renderCount = 0;
const loggedErrors = [];
const emptyData = {
agents: [],
skills: [],
commands: [],
rules: [],
mcps: [],
hooks: [],
};
await withDashboardServer(async (port) => {
const failedResponse = await requestDashboard(port);
assert.strictEqual(failedResponse.statusCode, 500);
assert.strictEqual(failedResponse.headers['cache-control'], 'no-store');
assert.strictEqual(
failedResponse.body,
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
);
assert.ok(!failedResponse.body.includes('sensitive render detail'));
const followUpResponse = await requestDashboard(port);
assert.strictEqual(followUpResponse.statusCode, 200);
assert.ok(followUpResponse.body.includes('ECC Capabilities'));
assert.strictEqual(loggedErrors.length, 1);
assert.strictEqual(loggedErrors[0].error.message, 'sensitive render detail');
}, {
loadData: () => emptyData,
render: (data) => {
renderCount++;
if (renderCount === 1) {
throw new Error('sensitive render detail');
}
return renderHTML(data);
},
reportError: (message, error) => {
loggedErrors.push({ error, message });
},
});
});
asyncTest('server rejects a missing or DNS-rebinding Host before routing', async () => {
await withDashboardServer(async (port) => {
const missingHost = await requestDashboardWithoutHost(port);
assert.strictEqual(missingHost.statusCode, 421);
assert.strictEqual(missingHost.headers['cache-control'], 'no-store');
const reboundHost = await requestDashboard(port, {
headers: { Host: 'dashboard.attacker.example' },
path: '/api/data',
});
assert.strictEqual(reboundHost.statusCode, 421);
assert.strictEqual(reboundHost.headers['cache-control'], 'no-store');
});
});
asyncTest('server rejects an allowed hostname with an invalid port without crashing', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, {
headers: { Host: 'localhost:99999' },
path: '/api/data',
});
assert.strictEqual(response.statusCode, 421);
assert.strictEqual(response.headers['cache-control'], 'no-store');
});
});
asyncTest('server returns a generic no-store 400 for a malformed absolute request target and remains usable', async () => {
await withDashboardServer(async (port) => {
const malformedResponse = await requestDashboard(port, {
path: 'http://attacker.example:99999/',
});
assert.strictEqual(malformedResponse.statusCode, 400);
assert.strictEqual(malformedResponse.headers['cache-control'], 'no-store');
assert.deepStrictEqual(JSON.parse(malformedResponse.body), {
error: 'Bad request',
});
const followUpResponse = await requestDashboard(port, {
path: '/api/data',
});
assert.strictEqual(followUpResponse.statusCode, 200);
assert.strictEqual(followUpResponse.headers['cache-control'], 'no-store');
});
});
asyncTest('server rejects cross-origin requests before routing', async () => {
await withDashboardServer(async (port) => {
const response = await requestDashboard(port, {
headers: { Origin: 'https://attacker.example' },
path: '/api/data',
});
assert.strictEqual(response.statusCode, 403);
assert.strictEqual(response.headers['cache-control'], 'no-store');
});
});
@ -853,5 +1154,21 @@ test('loadMcps handles empty mcp-configs directory', () => {
// ===================== Results =====================
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
process.exit(testFailed > 0 ? 1 : 0);
async function runAsyncTests() {
for (const { name, fn } of asyncTests) {
try {
await fn();
console.log(`${name}`);
testPassed++;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
testFailed++;
}
}
console.log(`\nResults: Passed: ${testPassed}, Failed: ${testFailed}`);
process.exitCode = testFailed > 0 ? 1 : 0;
}
runAsyncTests();