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

@ -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 = {