diff --git a/scripts/auto-update.js b/scripts/auto-update.js index c6b48119..52ae9d25 100644 --- a/scripts/auto-update.js +++ b/scripts/auto-update.js @@ -123,6 +123,12 @@ function determineInstallCwd(record, 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) { const normalized = path.resolve(repoRoot); 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}`); } + 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; } diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 4cdfffd4..53df1e3c 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -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); diff --git a/scripts/lib/path-safety.js b/scripts/lib/path-safety.js new file mode 100644 index 00000000..e1ad3dd1 --- /dev/null +++ b/scripts/lib/path-safety.js @@ -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, +}; diff --git a/tests/lib/path-safety.test.js b/tests/lib/path-safety.test.js new file mode 100644 index 00000000..bbf38794 --- /dev/null +++ b/tests/lib/path-safety.test.js @@ -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); +}