fix(security): contain install-state file ops to trusted root — RCE fix (GHSA-hfpv-w6mp-5g95)

Critical: project-local install-state (e.g. a cloned repo's .cursor/ecc-install-state.json)
is attacker-controllable, and repair/uninstall/auto-update replayed its operations with
destinationPath validated only for non-emptiness — confirmed arbitrary file write/delete
and chained RCE (write ~/.bashrc, .git/hooks, or run a planted install-apply.js).

- New scripts/lib/path-safety.js: assertWithinTrustedRoot() canonicalizes (incl. symlink
  escape via nearest-existing-ancestor realpath) and fails closed unless the destination is
  within the adapter-derived trusted root.
- install-lifecycle.js: gate executeRepairOperation + executeUninstallOperation + the
  install-state removal against record.targetRoot (the adapter-resolved root, NOT the
  attacker-supplied state.target.root).
- auto-update.js: validateRepoRoot now requires package.json name to be an official ECC
  package, so a planted nested repo can't drive auto-update into executing attacker code.
- 7 containment regression tests. Existing install-lifecycle/repair/uninstall/auto-update
  suites still green (legit destinations are within the root).
This commit is contained in:
Affaan Mustafa 2026-06-18 19:54:22 -04:00
parent 5e4f5533d7
commit 5994d3fac1
4 changed files with 201 additions and 4 deletions

View File

@ -123,6 +123,12 @@ function determineInstallCwd(record, repoRoot) {
return repoRoot; return repoRoot;
} }
// Recognized ECC package names. A repo root is only trusted to run its
// install-apply.js if its package.json identifies it as ECC — otherwise a
// cloned project that ships a nested `evil/{package.json,scripts/install-apply.js}`
// could drive auto-update into executing attacker code (GHSA-hfpv-w6mp-5g95).
const ECC_PACKAGE_NAMES = new Set(['ecc-universal', 'everything-claude-code']);
function validateRepoRoot(repoRoot) { function validateRepoRoot(repoRoot) {
const normalized = path.resolve(repoRoot); const normalized = path.resolve(repoRoot);
const packageJsonPath = path.join(normalized, 'package.json'); const packageJsonPath = path.join(normalized, 'package.json');
@ -136,6 +142,18 @@ function validateRepoRoot(repoRoot) {
throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`); throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`);
} }
let pkgName = null;
try {
pkgName = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).name;
} catch {
throw new Error(`Invalid ECC repo root: unreadable package.json at ${packageJsonPath}`);
}
if (!ECC_PACKAGE_NAMES.has(pkgName)) {
throw new Error(
`Refusing to run install from untrusted repo root ${normalized}: package.json name '${pkgName}' is not an official ECC package.`
);
}
return normalized; return normalized;
} }

View File

@ -4,6 +4,7 @@ const path = require('path');
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests'); const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
const { readInstallState, writeInstallState } = require('./install-state'); const { readInstallState, writeInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const { const {
createManifestInstallPlan, createManifestInstallPlan,
} = require('./install-executor'); } = require('./install-executor');
@ -309,7 +310,12 @@ function shouldRepairFromRecordedOperations(state) {
return getManagedOperations(state).some(operation => operation.kind !== 'copy-file'); return getManagedOperations(state).some(operation => operation.kind !== 'copy-file');
} }
function executeRepairOperation(repoRoot, operation) { function executeRepairOperation(repoRoot, operation, trustedRoot) {
// Install-state is attacker-controllable; never write/delete outside the
// adapter-derived trusted root, regardless of what the state file claims
// (GHSA-hfpv-w6mp-5g95).
assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'repair');
if (operation.kind === 'copy-file') { if (operation.kind === 'copy-file') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation); const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) { if (!sourcePath || !fs.existsSync(sourcePath)) {
@ -360,7 +366,10 @@ function executeRepairOperation(repoRoot, operation) {
throw new Error(`Unsupported repair operation kind: ${operation.kind}`); throw new Error(`Unsupported repair operation kind: ${operation.kind}`);
} }
function executeUninstallOperation(operation) { function executeUninstallOperation(operation, trustedRoot) {
// Confine deletes to the trusted install root (GHSA-hfpv-w6mp-5g95).
assertWithinTrustedRoot(operation.destinationPath, trustedRoot, 'uninstall');
if (operation.kind === 'copy-file') { if (operation.kind === 'copy-file') {
if (!fs.existsSync(operation.destinationPath)) { if (!fs.existsSync(operation.destinationPath)) {
return { return {
@ -1047,7 +1056,7 @@ function repairInstalledStates(options = {}) {
if (repairOperations.length > 0) { if (repairOperations.length > 0) {
for (const operation of repairOperations) { for (const operation of repairOperations) {
executeRepairOperation(context.repoRoot, operation); executeRepairOperation(context.repoRoot, operation, record.targetRoot);
} }
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview); writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
} else { } else {
@ -1161,12 +1170,13 @@ function uninstallInstalledStates(options = {}) {
const operations = getManagedOperations(state); const operations = getManagedOperations(state);
for (const operation of operations) { for (const operation of operations) {
const outcome = executeUninstallOperation(operation); const outcome = executeUninstallOperation(operation, record.targetRoot);
removedPaths.push(...outcome.removedPaths); removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets); cleanupTargets.push(...outcome.cleanupTargets);
} }
if (fs.existsSync(state.target.installStatePath)) { if (fs.existsSync(state.target.installStatePath)) {
assertWithinTrustedRoot(state.target.installStatePath, record.targetRoot, 'uninstall');
fs.rmSync(state.target.installStatePath, { force: true }); fs.rmSync(state.target.installStatePath, { force: true });
removedPaths.push(state.target.installStatePath); removedPaths.push(state.target.installStatePath);
cleanupTargets.push(state.target.installStatePath); cleanupTargets.push(state.target.installStatePath);

View File

@ -0,0 +1,84 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* Path containment helpers for install-state-driven file operations.
*
* Install-state files are project-local and therefore attacker-controllable
* (a cloned/forked repo can ship a crafted `.cursor/ecc-install-state.json`).
* `repair`/`uninstall`/`auto-update` replay recorded operations, so every
* write/delete destination MUST be confined to the adapter-derived trusted
* root never trusted from the state file itself (GHSA-hfpv-w6mp-5g95).
*/
function safeRealpath(target) {
try {
return fs.realpathSync(path.resolve(target));
} catch {
return path.resolve(target);
}
}
/**
* Canonicalize a path that may not exist yet: realpath its nearest existing
* ancestor, then re-append the missing tail. This defeats symlink escapes
* where an intermediate directory is a symlink pointing out of the root.
*/
function realpathNearestExisting(target) {
let current = path.resolve(target);
const tail = [];
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) {
break;
}
tail.unshift(path.basename(current));
current = parent;
}
const real = safeRealpath(current);
return tail.length > 0 ? path.join(real, ...tail) : real;
}
/**
* True when `target` resolves to `root` itself or a path beneath it, with
* symlinks resolved on both sides.
*/
function isWithinRoot(target, root) {
if (!root) {
return false;
}
const realRoot = safeRealpath(root);
const realTarget = realpathNearestExisting(target);
if (realTarget === realRoot) {
return true;
}
const rel = path.relative(realRoot, realTarget);
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
/**
* Fail-closed guard: throw unless `target` is contained within `root`.
* Returns the canonicalized target path on success.
*/
function assertWithinTrustedRoot(target, root, action = 'write') {
if (!target || typeof target !== 'string') {
throw new Error(`Refusing to ${action}: missing destination path.`);
}
if (!root) {
throw new Error(`Refusing to ${action} '${target}': no trusted install root resolved.`);
}
if (!isWithinRoot(target, root)) {
throw new Error(
`Refusing to ${action} outside the install root: '${target}' is not within '${root}'.`
);
}
return realpathNearestExisting(target);
}
module.exports = {
realpathNearestExisting,
isWithinRoot,
assertWithinTrustedRoot,
};

View File

@ -0,0 +1,85 @@
'use strict';
/**
* Tests for scripts/lib/path-safety.js the install-state containment guard
* that fixes arbitrary file write/delete via attacker-controlled install-state
* (GHSA-hfpv-w6mp-5g95).
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { assertWithinTrustedRoot, isWithinRoot } = require('../../scripts/lib/path-safety');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` ${error.message}`);
failed += 1;
}
}
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'path-safety-root-'));
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'path-safety-out-'));
try {
test('allows a path inside the trusted root', () => {
const p = path.join(root, '.cursor', 'rules', 'x.md');
// Returns the canonicalized path (symlinks like /var -> /private/var resolved).
assert.doesNotThrow(() => assertWithinTrustedRoot(p, root, 'repair'));
assert.ok(assertWithinTrustedRoot(p, root, 'repair').endsWith(path.join('.cursor', 'rules', 'x.md')));
assert.strictEqual(isWithinRoot(p, root), true);
});
test('allows the root itself', () => {
assert.strictEqual(isWithinRoot(root, root), true);
});
test('refuses an absolute path outside the root', () => {
const evil = path.join(outside, 'PWNED.txt');
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
assert.strictEqual(isWithinRoot(evil, root), false);
});
test('refuses a ../ traversal escape', () => {
const evil = path.join(root, '..', 'escape.txt');
assert.throws(() => assertWithinTrustedRoot(evil, root, 'uninstall'), /outside the install root/);
});
test('refuses a symlinked intermediate directory that escapes the root', () => {
const linkDir = path.join(root, 'link');
try {
fs.symlinkSync(outside, linkDir, 'dir');
} catch {
console.log(' (symlink unsupported on this platform; skipping)');
return;
}
// root/link -> outside, so root/link/PWNED resolves outside the root.
const evil = path.join(linkDir, 'PWNED.txt');
assert.throws(() => assertWithinTrustedRoot(evil, root, 'repair'), /outside the install root/);
});
test('refuses when no trusted root is resolved', () => {
assert.throws(() => assertWithinTrustedRoot(path.join(root, 'x'), null, 'repair'), /no trusted install root/);
});
test('refuses a missing destination path', () => {
assert.throws(() => assertWithinTrustedRoot('', root, 'repair'), /missing destination path/);
});
} finally {
fs.rmSync(root, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
if (failed > 0) {
process.exit(1);
}