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

@ -12,14 +12,35 @@
const fs = require('fs');
const path = require('path');
const http = require('http');
const {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
isAllowedHostHeader,
isAllowedOrigin,
} = require('./lib/loopback-guard');
const { normalizeAgentTools } = require('./lib/agent-tools');
const DEFAULT_HOST = '127.0.0.1';
function resolveDashboardHost(env = process.env) {
const configured = String(env.ECC_DASHBOARD_HOST || '').trim().toLowerCase();
if (!configured) return DEFAULT_HOST;
if (!LOOPBACK_HOSTNAMES.has(configured)) {
throw new Error(
'[ECC] ECC_DASHBOARD_HOST must be loopback-only ' +
'(127.0.0.1, localhost, or ::1).'
);
}
return configured === '[::1]' ? '::1' : configured;
}
function parsePort(v) {
const n = parseInt(String(v), 10);
if (isNaN(n) || n < 1 || n > 65535) { console.error('[ECC] Invalid port: ' + v + ' — using 3456'); return 3456; }
return n;
}
const PORT = parsePort(process.argv[2] || process.env.ECC_DASHBOARD_PORT || '3456');
const HOST = resolveDashboardHost();
const ROOT = path.resolve(__dirname, '..');
function readFrontmatter(p) {
@ -791,21 +812,140 @@ handleRoute();
/* eslint-enable no-useless-escape */
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/api/data') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(renderHTML({ agents: loadAgents(), skills: loadSkills(), commands: loadCommands(), rules: loadRules(), mcps: loadMcps(), hooks: loadHooks() }));
});
function sendJson(res, statusCode, payload) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
});
res.end(JSON.stringify(payload));
}
if (require.main === module) {
server.listen(PORT, () => {
console.log(`\n ECC Capabilities → http://localhost:${PORT}\n`);
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', `http://localhost:${PORT}`], { stdio: 'ignore' }); else spawn(c, [`http://localhost:${PORT}`], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
function sendHtml(res, statusCode, html) {
res.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
res.end(html);
}
function loadDashboardData(root) {
return {
agents: loadAgents(root),
skills: loadSkills(root),
commands: loadCommands(root),
rules: loadRules(root),
mcps: loadMcps(root),
hooks: loadHooks(root),
};
}
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);
return http.createServer((req, res) => {
if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) {
return sendJson(res, 421, { error: 'Misdirected request' });
}
if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) {
return sendJson(res, 403, { error: 'Forbidden origin' });
}
let url;
try {
url = new URL(req.url, `http://${DEFAULT_HOST}`);
} catch {
return sendJson(res, 400, { error: 'Bad request' });
}
if (url.pathname === '/api/data') {
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);
}
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);
});
}
module.exports = { parsePort, readFrontmatter, readSkill, loadAgents, loadSkills, loadCommands, loadRules, loadMcps, loadHooks, renderHTML, LANG, LANG_KEYS, server };
function listenDashboardServer(
dashboardServer,
{ port = PORT, host = HOST, onListening } = {}
) {
const resolvedHost = resolveDashboardHost({ ECC_DASHBOARD_HOST: host });
return dashboardServer.listen(port, resolvedHost, onListening);
}
const server = createDashboardServer();
if (require.main === module) {
listenDashboardServer(server, { port: PORT, host: HOST, onListening: () => {
const displayHost = HOST.includes(':') ? `[${HOST}]` : HOST;
const dashboardUrl = `http://${displayHost}:${PORT}`;
console.log(`\n ECC Capabilities → ${dashboardUrl}\n`);
try { const { spawn } = require('child_process'); const p = process.platform; const c = p === 'darwin' ? 'open' : p === 'win32' ? 'start' : 'xdg-open'; if (c === 'start') spawn('cmd', ['/c', 'start', dashboardUrl], { stdio: 'ignore' }); else spawn(c, [dashboardUrl], { stdio: 'ignore' }); } catch { /* best-effort auto-open */ }
} });
}
module.exports = {
DEFAULT_HOST,
HOST,
LANG,
LANG_KEYS,
createDashboardServer,
listenDashboardServer,
loadAgents,
loadCommands,
loadHooks,
loadMcps,
loadRules,
loadSkills,
parsePort,
readFrontmatter,
readSkill,
renderHTML,
resolveDashboardHost,
server,
};

View file

@ -14,6 +14,7 @@
const fs = require('fs');
const path = require('path');
const { assertWithinTrustedRoot } = require('./path-safety');
const AGENT_DATA_HOME_ENV = 'ECC_AGENT_DATA_HOME';
const DEFAULT_CLAUDE_DIR_NAME = '.claude';
@ -94,6 +95,41 @@ function getDefaultClaudeAgentDataHome() {
return path.join(getHomeDirFromEnv(), DEFAULT_CLAUDE_DIR_NAME);
}
function warnUnsafeProjectConfig() {
console.error(
'[ECC] Ignoring unsafe agent data project config: agentDataHome must stay ' +
'within the default Cursor or Claude data directories. Use ' +
'ECC_AGENT_DATA_HOME for an explicit trusted override.'
);
}
function isSafeProjectConfigSyntax(candidate) {
const trimmed = candidate.trim();
const isUserAnchored = trimmed.startsWith('~') || path.isAbsolute(trimmed);
const hasParentTraversal = trimmed.split(/[/\\]+/).includes('..');
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;
@ -103,8 +139,18 @@ function readProjectConfigAt(configPath) {
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const candidate = parsed.agentDataHome || parsed.ECC_AGENT_DATA_HOME;
if (typeof candidate !== 'string' || !candidate.trim()) return null;
if (!isSafeProjectConfigSyntax(candidate)) {
warnUnsafeProjectConfig();
return null;
}
const projectRoot = resolveProjectRootFromConfigPath(configPath);
return expandHomePath(candidate, projectRoot);
const resolved = expandHomePath(candidate, projectRoot);
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}`

File diff suppressed because it is too large Load diff

View file

@ -15,8 +15,12 @@ function parseHostHeader(value) {
if (!value || typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::\d+)?$/);
const match = trimmed.match(/^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/);
if (!match) return null;
if (match[2] !== undefined) {
const port = Number(match[2]);
if (!Number.isInteger(port) || port > 65535) return null;
}
return match[1].toLowerCase();
}

View file

@ -13,11 +13,15 @@ const path = require('path');
* root - never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
*/
function safeRealpath(target) {
function pathEntryExists(target) {
try {
return fs.realpathSync(path.resolve(target));
} catch {
return path.resolve(target);
fs.lstatSync(target);
return true;
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
return false;
}
throw error;
}
}
@ -29,7 +33,7 @@ function safeRealpath(target) {
function realpathNearestExisting(target) {
let current = path.resolve(target);
const tail = [];
while (!fs.existsSync(current)) {
while (!pathEntryExists(current)) {
const parent = path.dirname(current);
if (parent === current) {
break;
@ -37,7 +41,7 @@ function realpathNearestExisting(target) {
tail.unshift(path.basename(current));
current = parent;
}
const real = safeRealpath(current);
const real = fs.realpathSync(current);
return tail.length > 0 ? path.join(real, ...tail) : real;
}
@ -45,17 +49,32 @@ function realpathNearestExisting(target) {
* True when `target` resolves to `root` itself or a path beneath it, with
* symlinks resolved on both sides.
*/
function resolveContainment(target, root) {
const realRoot = realpathNearestExisting(root);
const realTarget = realpathNearestExisting(target);
const relativePath = path.relative(realRoot, realTarget);
const contained = relativePath === ''
|| (
relativePath !== '..'
&& !relativePath.startsWith(`..${path.sep}`)
&& !path.isAbsolute(relativePath)
);
return {
contained,
realRoot,
realTarget
};
}
function isWithinRoot(target, root) {
if (!root) {
return false;
}
const realRoot = safeRealpath(root);
const realTarget = realpathNearestExisting(target);
if (realTarget === realRoot) {
return true;
try {
return resolveContainment(target, root).contained;
} catch {
return false;
}
const rel = path.relative(realRoot, realTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/**
@ -69,10 +88,17 @@ function assertWithinTrustedRoot(target, root, action = 'write') {
if (!root) {
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
}
if (!isWithinRoot(target, root)) {
let containment;
try {
containment = resolveContainment(target, root);
} catch {
containment = null;
}
if (!containment || !containment.contained) {
throw new Error(`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`);
}
return realpathNearestExisting(target);
return containment.realTarget;
}
module.exports = {