fix: close dashboard hardening review gaps

This commit is contained in:
Affaan Mustafa
2026-07-26 05:34:10 -04:00
parent 9b81e51602
commit 7aa943a9ab
6 changed files with 482 additions and 67 deletions
+48 -3
View File
@@ -834,7 +834,25 @@ function loadDashboardData(root) {
};
}
function createDashboardServer({ root = ROOT, host = HOST } = {}) {
function defaultReportError(message, error) {
console.error(message, error);
}
function reportDashboardFailure(reportError, message, error) {
try {
reportError(message, error);
} catch {
// Error reporting must never prevent the generic HTTP response.
}
}
function createDashboardServer({
root = ROOT,
host = HOST,
loadData = loadDashboardData,
render = renderHTML,
reportError = defaultReportError,
} = {}) {
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
const allowedHostnames = buildAllowedHostnames(resolvedHost);
@@ -854,9 +872,36 @@ function createDashboardServer({ root = ROOT, host = HOST } = {}) {
}
if (url.pathname === '/api/data') {
return sendJson(res, 200, loadDashboardData(root));
let data;
try {
data = loadData(root);
} catch (error) {
reportDashboardFailure(
reportError,
'[ECC] Failed to load dashboard data:',
error
);
return sendJson(res, 500, { error: 'Internal server error' });
}
return sendJson(res, 200, data);
}
return sendHtml(res, 200, renderHTML(loadDashboardData(root)));
let html;
try {
html = render(loadData(root));
} catch (error) {
reportDashboardFailure(
reportError,
'[ECC] Failed to render dashboard:',
error
);
return sendHtml(
res,
500,
'<!DOCTYPE html><p>Dashboard unavailable.</p>'
);
}
return sendHtml(res, 200, html);
});
}
+25 -9
View File
@@ -98,8 +98,8 @@ function getDefaultClaudeAgentDataHome() {
function warnUnsafeProjectConfig() {
console.error(
'[ECC] Ignoring unsafe agent data project config: agentDataHome must stay ' +
'within the default Cursor data directory. Use ECC_AGENT_DATA_HOME for an ' +
'explicit trusted override.'
'within the default Cursor or Claude data directories. Use ' +
'ECC_AGENT_DATA_HOME for an explicit trusted override.'
);
}
@@ -110,6 +110,26 @@ function isSafeProjectConfigSyntax(candidate) {
return isUserAnchored && !hasParentTraversal;
}
function resolveAllowedProjectConfigHome(candidate) {
const allowedRoots = [
getDefaultCursorAgentDataHome(),
getDefaultClaudeAgentDataHome(),
];
for (const allowedRoot of allowedRoots) {
try {
return assertWithinTrustedRoot(
candidate,
allowedRoot,
'use project agent data home'
);
} catch {
// Try the next explicitly allowed default root.
}
}
return null;
}
function readProjectConfigAt(configPath) {
if (!configPath || typeof configPath !== 'string') return null;
if (!fs.existsSync(configPath)) return null;
@@ -125,16 +145,12 @@ function readProjectConfigAt(configPath) {
}
const projectRoot = resolveProjectRootFromConfigPath(configPath);
const resolved = expandHomePath(candidate, projectRoot);
try {
return assertWithinTrustedRoot(
resolved,
getDefaultCursorAgentDataHome(),
'use project agent data home'
);
} catch {
const allowedHome = resolveAllowedProjectConfigHome(resolved);
if (!allowedHome) {
warnUnsafeProjectConfig();
return null;
}
return allowedHome;
} catch (error) {
console.error(
`[ECC] Failed to read or parse agent data config at ${configPath}: ${error.message}`
+97 -46
View File
@@ -329,14 +329,69 @@ function getContainedExistingPath(
return finalDestination.exists ? finalDestination.managedPath : null;
}
function writeFileNoFollow(filePath, content, mode) {
function hasSameFileIdentity(leftStat, rightStat) {
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function createChangedDestinationError(action) {
return new Error(
`Refusing to ${action}: managed destination changed during the write.`
);
}
function getStableParentStat(filePath, action) {
const parentStat = fs.lstatSync(path.dirname(filePath));
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
throw createChangedDestinationError(action);
}
return parentStat;
}
function assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
) {
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
throw createChangedDestinationError(action);
}
const liveParentStat = getStableParentStat(filePath, action);
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
throw createChangedDestinationError(action);
}
const descriptorStat = fs.fstatSync(fileDescriptor);
const livePathStat = fs.lstatSync(liveDestination.managedPath);
if (
!descriptorStat.isFile()
|| !livePathStat.isFile()
|| livePathStat.isSymbolicLink()
|| !hasSameFileIdentity(descriptorStat, livePathStat)
) {
throw createChangedDestinationError(action);
}
}
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
const expectedParentStat = getStableParentStat(filePath, action);
const flags = fs.constants.O_WRONLY
| fs.constants.O_CREAT
| fs.constants.O_TRUNC
| (fs.constants.O_NOFOLLOW || 0);
const fileDescriptor = fs.openSync(filePath, flags, mode);
try {
assertPinnedWriteDestination(
filePath,
fileDescriptor,
expectedParentStat,
trustedRoot,
action
);
fs.ftruncateSync(fileDescriptor, 0);
fs.writeFileSync(fileDescriptor, content);
if (mode !== undefined) {
fs.fchmodSync(fileDescriptor, mode);
@@ -379,7 +434,13 @@ function writeContainedFile(destinationPath, content, trustedRoot, action, mode)
trustedRoot,
action
).managedPath;
writeFileNoFollow(finalDestination, content, mode);
writeFileNoFollow(
finalDestination,
content,
mode,
trustedRoot,
action
);
return finalDestination;
}
@@ -938,6 +999,27 @@ function getUnsafeManagedDestinationError(operationHealth) {
return 'Refusing unsafe managed destination outside adapter-derived install root.';
}
function getUnsafeOperationResult(record, operationHealth) {
const error = operationHealth.unsafeDestination.length > 0
? getUnsafeManagedDestinationError(operationHealth)
: operationHealth.unsafeSource.length > 0
? createUnsafeRepairSourceError().message
: null;
if (!error) {
return null;
}
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error
};
}
function buildDiscoveryRecord(adapter, context) {
const installTargetInput = {
homeDir: context.homeDir,
@@ -1346,27 +1428,12 @@ function repairInstalledStates(options = {}) {
record.targetRoot,
desiredPlan.operations
);
if (operationHealth.unsafeDestination.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: getUnsafeManagedDestinationError(operationHealth)
};
}
if (operationHealth.unsafeSource.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: createUnsafeRepairSourceError().message
};
const unsafeOperationResult = getUnsafeOperationResult(
record,
operationHealth
);
if (unsafeOperationResult) {
return unsafeOperationResult;
}
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
@@ -1404,28 +1471,12 @@ function repairInstalledStates(options = {}) {
desiredPlan.operations
);
if (operationHealth.unsafeDestination.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: getUnsafeManagedDestinationError(operationHealth)
};
}
if (operationHealth.unsafeSource.length > 0) {
return {
adapter: record.adapter,
status: 'error',
installStatePath: record.installStatePath,
repairedPaths: [],
plannedRepairs: [],
stateRefreshed: false,
error: createUnsafeRepairSourceError().message
};
const unsafeOperationResult = getUnsafeOperationResult(
record,
operationHealth
);
if (unsafeOperationResult) {
return unsafeOperationResult;
}
if (operationHealth.missingSource.length > 0) {
+89 -1
View File
@@ -192,6 +192,49 @@ function runTests() {
}
})) passed++; else failed++;
if (test('allows the documented ~/.claude project sharing root and its descendants', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-user-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true });
try {
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
const cases = [
{
candidate: '~/.claude',
expected: path.join(fs.realpathSync(homeDir), '.claude'),
},
{
candidate: '~/.claude/shared',
expected: path.join(fs.realpathSync(homeDir), '.claude', 'shared'),
},
];
for (const { candidate, expected } of cases) {
fs.writeFileSync(
configPath,
JSON.stringify({ agentDataHome: candidate }),
'utf8'
);
assert.strictEqual(
agentDataHome.readProjectConfigAt(configPath),
expected
);
}
});
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('rejects a relative agentDataHome that redirects into the project', () => {
const stamp = Date.now();
const projectDir = path.join(os.tmpdir(), `ecc-agent-data-home-relative-${stamp}`);
@@ -267,7 +310,7 @@ function runTests() {
}
})) passed++; else failed++;
if (test('rejects traversal and absolute project config paths outside the Cursor data root', () => {
if (test('rejects traversal and absolute project config paths outside the allowed data roots', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-unsafe-user-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
@@ -284,6 +327,11 @@ function runTests() {
'../../repo-data',
path.join(projectDir, 'absolute-data'),
'~/.cursor/ecc/profiles/../traversed-data',
'~/.claude/profiles/../traversed-data',
'~/.claude-other',
'~/.config/ecc',
'~',
path.join(homeDir, 'arbitrary-agent-data'),
];
for (const candidate of unsafeCandidates) {
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
@@ -301,6 +349,46 @@ function runTests() {
}
})) passed++; else failed++;
if (test('rejects a Claude project config destination that escapes through a symlink', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-user-'));
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-claude-link-outside-'));
const configPath = path.join(projectDir, '.cursor', 'ecc-agent-data.json');
const claudeRoot = path.join(homeDir, '.claude');
const linkPath = path.join(claudeRoot, 'redirect');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.mkdirSync(claudeRoot, { recursive: true });
try {
try {
fs.symlinkSync(outsideDir, linkPath, 'dir');
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
const candidate = path.join(linkPath, 'session-data');
fs.writeFileSync(configPath, JSON.stringify({ agentDataHome: candidate }), 'utf8');
withEnv({
ECC_AGENT_DATA_HOME: undefined,
HOME: homeDir,
USERPROFILE: undefined,
}, () => {
const agentDataHome = require('../../scripts/lib/agent-data-home');
const { result, messages } = captureConsoleErrors(
() => agentDataHome.readProjectConfigAt(configPath)
);
assert.strictEqual(result, null);
assert.ok(messages.some(message => message.includes('Ignoring unsafe agent data project config')));
assert.ok(messages.every(message => !message.includes(candidate)));
});
} finally {
fs.rmSync(projectDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(outsideDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('allows a non-existent project config destination beneath the Cursor data root', () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-'));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-agent-data-home-safe-user-'));
+92 -1
View File
@@ -1619,6 +1619,7 @@ function runTests() {
if (test('repair uses no-follow writes when a final destination becomes a symlink', () => {
if (!fs.constants.O_NOFOLLOW) {
console.log(' (O_NOFOLLOW unsupported on this platform; skipping)');
return;
}
@@ -1677,6 +1678,92 @@ function runTests() {
}
})) passed++; else failed++;
if (test('repair revalidates a pinned write before a swapped parent can truncate outside files', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
const outsideRoot = createTempDir('install-lifecycle-outside-');
const targetRoot = path.join(projectRoot, '.cursor');
const destinationParent = path.join(targetRoot, 'late-parent');
const backupParent = path.join(targetRoot, 'late-parent-backup');
const destinationPath = path.join(destinationParent, 'managed.md');
const outsideDestinationPath = path.join(outsideRoot, 'managed.md');
const originalOpenSync = fs.openSync;
let canonicalDestinationPath;
let insertedSymlink = false;
let result;
const symlinkProbe = path.join(targetRoot, 'parent-symlink-probe');
try {
fs.mkdirSync(targetRoot, { recursive: true });
fs.symlinkSync(
outsideRoot,
symlinkProbe,
process.platform === 'win32' ? 'junction' : 'dir'
);
fs.rmSync(symlinkProbe, { force: true });
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
return;
}
try {
fs.mkdirSync(destinationParent, { recursive: true });
fs.writeFileSync(destinationPath, 'drifted managed content\n');
fs.writeFileSync(outsideDestinationPath, 'outside sentinel\n');
canonicalDestinationPath = fs.realpathSync(destinationPath);
writeCursorState(projectRoot, {
operations: [
managedOperation('copy-file', destinationPath, { strategy: 'copy-file' }),
],
});
fs.openSync = function openSyncWithLateParentSwap(filePath, flags, mode) {
const isDestinationWrite = path.resolve(filePath) === canonicalDestinationPath
&& typeof flags === 'number'
&& (flags & fs.constants.O_WRONLY) === fs.constants.O_WRONLY;
if (!insertedSymlink && isDestinationWrite) {
fs.renameSync(destinationParent, backupParent);
fs.symlinkSync(
outsideRoot,
destinationParent,
process.platform === 'win32' ? 'junction' : 'dir'
);
insertedSymlink = true;
}
return originalOpenSync.call(fs, filePath, flags, mode);
};
result = repairInstalledStates({
repoRoot: REPO_ROOT,
homeDir,
projectRoot,
targets: ['cursor'],
});
} finally {
fs.openSync = originalOpenSync;
}
try {
assert.strictEqual(insertedSymlink, true);
assert.strictEqual(result.results[0].status, 'error');
assert.strictEqual(
fs.readFileSync(outsideDestinationPath, 'utf8'),
'outside sentinel\n'
);
assert.strictEqual(
fs.readFileSync(path.join(backupParent, 'managed.md'), 'utf8'),
'drifted managed content\n'
);
} finally {
cleanup(homeDir);
cleanup(projectRoot);
cleanup(outsideRoot);
}
})) passed++; else failed++;
if (test('repair refreshes only the adapter-derived install-state path', () => {
const homeDir = createTempDir('install-lifecycle-home-');
const projectRoot = createTempDir('install-lifecycle-project-');
@@ -1690,6 +1777,7 @@ function runTests() {
installStatePath: recordedStatePath,
});
writeState(adapterStatePath, stateOptions);
fs.writeFileSync(recordedStatePath, 'outside sentinel\n');
const result = repairInstalledStates({
repoRoot: REPO_ROOT,
@@ -1700,7 +1788,10 @@ function runTests() {
assert.strictEqual(result.results[0].status, 'ok');
assert.ok(fs.existsSync(adapterStatePath));
assert.ok(!fs.existsSync(recordedStatePath));
assert.strictEqual(
fs.readFileSync(recordedStatePath, 'utf8'),
'outside sentinel\n'
);
const refreshedState = readInstallState(adapterStatePath);
assert.strictEqual(refreshedState.target.root, targetRoot);
assert.strictEqual(refreshedState.target.installStatePath, adapterStatePath);
+131 -7
View File
@@ -15,6 +15,7 @@ let testRoot;
let testPassed = 0;
let testFailed = 0;
const asyncTests = [];
const REQUEST_TIMEOUT_MS = 5000;
function test(name, fn) {
try {
@@ -50,7 +51,23 @@ function writeFile(rootDir, relativePath, content) {
function requestDashboard(port, options = {}) {
return new Promise((resolve, reject) => {
const request = http.request({
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',
@@ -63,15 +80,16 @@ function requestDashboard(port, options = {}) {
response.on('data', (chunk) => {
body += chunk;
});
response.on('error', error => settle(reject, error));
response.on('end', () => {
resolve({
settle(resolve, {
body,
headers: response.headers,
statusCode: response.statusCode,
});
});
});
request.on('error', reject);
request.on('error', error => settle(reject, error));
request.end();
});
}
@@ -80,6 +98,21 @@ 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');
@@ -97,15 +130,23 @@ function requestDashboardWithoutHost(port) {
if (separator < 1) continue;
headers[line.slice(0, separator).toLowerCase()] = line.slice(separator + 1).trim();
}
resolve({ body, headers, statusCode });
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'));
}
});
socket.on('error', reject);
});
}
async function withDashboardServer(fn) {
async function withDashboardServer(fn, serverOptions = {}) {
const { createDashboardServer } = require(SCRIPT);
const testServer = createDashboardServer({ host: '127.0.0.1' });
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', () => {
@@ -828,6 +869,89 @@ asyncTest('server returns no-store JSON on GET /api/data', async () => {
});
});
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);