mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
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:
parent
4da6deac18
commit
382060905e
10 changed files with 2320 additions and 193 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue