Skip to content

Commit 5b1a47c

Browse files
oalanicolasclaude
andcommitted
fix(security): add TOCTOU symlink checks in repair method
- Walk each path component to detect symlinks before copy - Add realpath verification to catch race conditions - Skip files with symlinks in path and log reason - Prevents directory-to-symlink replacement attacks Closes #60 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 54bf628 commit 5b1a47c

File tree

1 file changed

+54
-0
lines changed

1 file changed

+54
-0
lines changed

packages/installer/src/installer/post-install-validator.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,6 +1263,60 @@ class PostInstallValidator {
12631263
continue;
12641264
}
12651265

1266+
// SECURITY [TOCTOU]: Verify target path has no symlinks in any component
1267+
// This prevents race condition attacks where a directory is replaced with a symlink
1268+
// between the containment check and the actual copy operation
1269+
try {
1270+
const targetDir = path.dirname(targetPath);
1271+
1272+
// Walk each path component from aiosCoreTarget to targetDir
1273+
// and verify none are symlinks
1274+
let currentPath = this.aiosCoreTarget;
1275+
const relativeParts = path.relative(this.aiosCoreTarget, targetDir).split(path.sep);
1276+
1277+
for (const part of relativeParts) {
1278+
if (!part || part === '.') continue;
1279+
currentPath = path.join(currentPath, part);
1280+
1281+
// Check if this path component exists and is a symlink
1282+
try {
1283+
const componentStat = fs.lstatSync(currentPath);
1284+
if (componentStat.isSymbolicLink()) {
1285+
result.skipped.push({
1286+
path: relativePath,
1287+
reason: `Symlink detected in path component: ${path.relative(this.aiosCoreTarget, currentPath)}`,
1288+
});
1289+
continue;
1290+
}
1291+
} catch (_statError) {
1292+
// Path component doesn't exist yet, will be created by ensureDir
1293+
// This is OK - we'll create it as a real directory
1294+
break;
1295+
}
1296+
}
1297+
1298+
// Final realpath verification: ensure resolved target stays within resolved aiosCoreTarget
1299+
// This catches any symlinks that might have been missed or created during the check
1300+
if (fs.existsSync(targetDir)) {
1301+
const realTargetDir = fs.realpathSync(targetDir);
1302+
const realAiosCoreTarget = fs.realpathSync(this.aiosCoreTarget);
1303+
1304+
if (!isPathContained(realTargetDir, realAiosCoreTarget)) {
1305+
result.skipped.push({
1306+
path: relativePath,
1307+
reason: `Realpath escapes target directory: ${realTargetDir} is outside ${realAiosCoreTarget}`,
1308+
});
1309+
continue;
1310+
}
1311+
}
1312+
} catch (toctouError) {
1313+
result.skipped.push({
1314+
path: relativePath,
1315+
reason: `TOCTOU verification failed: ${toctouError.message}`,
1316+
});
1317+
continue;
1318+
}
1319+
12661320
// Perform copy
12671321
try {
12681322
await fs.ensureDir(path.dirname(targetPath));

0 commit comments

Comments
 (0)