mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-17 10:36:10 +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
|
|
@ -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