Skip to content

Commit 08e51a3

Browse files
authored
fix(security) : Fix race condition in file write operations (Symlink TOCTOU) (#1146)
* fix(files): prevent symlink TOCTOU in WriteFiles * test(files): add symlink TOCTOU protection tests Add test coverage for symlink attack prevention and path traversal blocking in WriteFiles operations. * Remove test additions - only keep security fixes in source
1 parent 4eb9dca commit 08e51a3

1 file changed

Lines changed: 33 additions & 6 deletions

File tree

src/core/files.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -550,16 +550,43 @@ export class Files {
550550
debug('Skipping file with undefined localPath.');
551551
return;
552552
}
553-
const resolvedWritePath = await fs.realpath(path.resolve(file.localPath)).catch(() => path.resolve(file.localPath));
554-
if (!isInside(absoluteContentDir, resolvedWritePath)) {
555-
debug('Skipping file outside content dir: %s', resolvedWritePath);
553+
554+
const targetPath = path.resolve(absoluteContentDir, file.localPath);
555+
556+
if (!isInside(absoluteContentDir, targetPath)) {
557+
debug('Skipping file outside content dir: %s', targetPath);
556558
return;
557559
}
558-
const localDirname = path.dirname(resolvedWritePath);
559-
if (localDirname !== '.') {
560+
561+
try {
562+
const realPath = await fs.realpath(targetPath);
563+
if (realPath !== targetPath) {
564+
debug('Skipping symlink target: %s -> %s', targetPath, realPath);
565+
return;
566+
}
567+
} catch {
568+
// File doesn't exist yet, proceed
569+
}
570+
571+
const localDirname = path.dirname(targetPath);
572+
if (localDirname !== absoluteContentDir) {
560573
await fs.mkdir(localDirname, {recursive: true});
561574
}
562-
await fs.writeFile(resolvedWritePath, file.source);
575+
576+
try {
577+
const fd = await fs.open(targetPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW, 0o644);
578+
try {
579+
await fs.writeFile(fd, file.source);
580+
} finally {
581+
await fd.close();
582+
}
583+
} catch (err: any) {
584+
if (err.code === 'ELOOP' || err.code === 'EUNKNOWN') {
585+
debug('Skipping symlink: %s', targetPath);
586+
return;
587+
}
588+
throw err;
589+
}
563590
};
564591
return await pMap(files, mapper);
565592
}

0 commit comments

Comments
 (0)