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
+14 -4
View File
@@ -4,6 +4,7 @@ const path = require('path');
const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests');
const { readInstallState, writeInstallState } = require('./install-state');
const { assertWithinTrustedRoot } = require('./path-safety');
const {
createManifestInstallPlan,
} = require('./install-executor');
@@ -309,7 +310,12 @@ function shouldRepairFromRecordedOperations(state) {
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') {
const sourcePath = resolveOperationSourcePath(repoRoot, operation);
if (!sourcePath || !fs.existsSync(sourcePath)) {
@@ -360,7 +366,10 @@ function executeRepairOperation(repoRoot, operation) {
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 (!fs.existsSync(operation.destinationPath)) {
return {
@@ -1047,7 +1056,7 @@ function repairInstalledStates(options = {}) {
if (repairOperations.length > 0) {
for (const operation of repairOperations) {
executeRepairOperation(context.repoRoot, operation);
executeRepairOperation(context.repoRoot, operation, record.targetRoot);
}
writeInstallState(desiredPlan.installStatePath, desiredPlan.statePreview);
} else {
@@ -1161,12 +1170,13 @@ function uninstallInstalledStates(options = {}) {
const operations = getManagedOperations(state);
for (const operation of operations) {
const outcome = executeUninstallOperation(operation);
const outcome = executeUninstallOperation(operation, record.targetRoot);
removedPaths.push(...outcome.removedPaths);
cleanupTargets.push(...outcome.cleanupTargets);
}
if (fs.existsSync(state.target.installStatePath)) {
assertWithinTrustedRoot(state.target.installStatePath, record.targetRoot, 'uninstall');
fs.rmSync(state.target.installStatePath, { force: true });
removedPaths.push(state.target.installStatePath);
cleanupTargets.push(state.target.installStatePath);