fix(security): reject Windows reparse digests

This commit is contained in:
haelyra
2026-08-13 18:29:21 -04:00
parent 60ab63fe21
commit 42d219e0de
2 changed files with 45 additions and 5 deletions
+16 -4
View File
@@ -96,13 +96,22 @@ function readInstalledFileNoFollow(plan, operation) {
}
try {
const openedStat = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(operation.destinationPath, { bigint: true });
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile()) {
return null;
}
const identityMatches = openedStat.ino === finalPathStat.ino
&& (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev);
if (!openedStat.isFile() || !identityMatches) {
throw new Error(
`Refusing to hash changed install destination: ${operation.destinationPath}`
);
}
// Revalidate the full path after opening. The descriptor pins the file so
// the digest and metadata refer to the same object.
assertSafeInstallOperation(plan, operation);
assertSafeClaudeSkillOperation(plan, operation);
if (!fs.fstatSync(descriptor).isFile()) {
return null;
}
return fs.readFileSync(descriptor);
} finally {
fs.closeSync(descriptor);
@@ -457,7 +466,10 @@ function applyInstallPlan(plan, dependencies = {}) {
stateWithContentDigests(migration.bridgeState, appliedPlan)
);
} catch (checkpointError) {
error.message += ` Install-state checkpoint also failed: ${checkpointError.message}`;
throw new Error(
`${error.message} Install-state checkpoint also failed: ${checkpointError.message}`,
{ cause: error }
);
}
}
throw error;
+29 -1
View File
@@ -188,7 +188,16 @@ function managedOperation(kind, destinationPath, overrides = {}) {
destinationPath,
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)
);
if (fs.fstatSync(descriptor).isFile()) {
const openedStat = fs.fstatSync(descriptor, { bigint: true });
const finalPathStat = fs.lstatSync(destinationPath, { bigint: true });
const identityMatches = openedStat.ino === finalPathStat.ino
&& (!openedStat.dev || !finalPathStat.dev || openedStat.dev === finalPathStat.dev);
if (
openedStat.isFile()
&& finalPathStat.isFile()
&& !finalPathStat.isSymbolicLink()
&& identityMatches
) {
operation.contentSha256 = crypto.createHash('sha256')
.update(fs.readFileSync(descriptor))
.digest('hex');
@@ -212,6 +221,25 @@ function runTests() {
let passed = 0;
let failed = 0;
if (test('managed-operation digest never follows a final symlink', () => {
const tempDir = createTempDir('install-lifecycle-symlink-digest-');
const victimPath = path.join(tempDir, 'victim.md');
const symlinkPath = path.join(tempDir, 'managed.md');
try {
fs.writeFileSync(victimPath, 'user content\n');
try {
fs.symlinkSync(victimPath, symlinkPath, 'file');
} catch {
console.log(' (file symlink unsupported on this platform; skipping)');
return;
}
const operation = managedOperation('copy-file', symlinkPath);
assert.strictEqual(operation.contentSha256, undefined);
} finally {
cleanup(tempDir);
}
})) passed++; else failed++;
if (test('normalizes default targets and dedupes adapter aliases', () => {
const defaultTargets = normalizeTargets();