fix(core): close TOCTOU window on noFollow EXDEV fallback + cover ENOENT path

Two PR #4333 round-7 review items folded in:

1. TOCTOU race: the path-based `tryChmod(targetPath)` after the EXDEV
   noFollow branch ran AFTER `fd.close()` released the inode reference.
   Between close and chmod, an attacker with parent-directory write access
   could replace the regular file with a symlink, redirecting the
   `chmod 0o600` onto an attacker-chosen target — silently defeating the
   `noFollow` protection that the `unlink + O_EXCL` pattern was added to
   provide. Fix: switch to `fd.chmod`/`fchmodSync` on the open fd before
   close (operates on the inode, immune to symlink swap), and skip the
   path-based chmod for the noFollow branch (path-based chmod remains
   for the non-noFollow direct-write branch, where following symlinks
   was already in scope).

2. Missing test coverage: all 4 existing noFollow EXDEV tests pre-place
   a symlink at the target, so `unlink(targetPath)` always succeeded —
   the ENOENT-swallow branch (first-write scenarios, e.g. initial
   credential provisioning on a cross-device mount) had no coverage.
   Added 2 tests (async + sync) verifying the fallback creates a new
   file with the requested mode when the target never existed.

Test results: 54/54 atomicFileWrite tests pass (was 52). Async + sync
parity preserved.

Refs: #4333, #4095 Phase 2
This commit is contained in:
doudouOUC 2026-05-20 16:29:21 +08:00
parent 21853b87d6
commit 7c476648e9
2 changed files with 65 additions and 2 deletions

View file

@ -891,4 +891,48 @@ describe('noFollow option — symlink protection', () => {
expect(fsSync.readFileSync(link, 'utf-8')).toBe('NEW');
expect(fsSync.readFileSync(real, 'utf-8')).toBe('ORIGINAL');
});
// Earlier noFollow EXDEV tests pre-place a symlink, so the
// `unlink(targetPath)` in the fallback always succeeds. These exercise
// the ENOENT-swallow branch — first-write scenarios (initial credential
// provisioning on a cross-device mount).
it('atomicWriteFile: noFollow EXDEV fallback creates a new file when target does not exist', async () => {
const target = path.join(tmpDir, 'never-created.txt');
const exdevRename = async () => {
const e: NodeJS.ErrnoException = new Error('EXDEV');
e.code = 'EXDEV';
throw e;
};
await atomicWriteFile(
target,
'NEW',
{ noFollow: true, mode: 0o600 },
{ rename: exdevRename },
);
expect(fsSync.lstatSync(target).isSymbolicLink()).toBe(false);
expect(await fs.readFile(target, 'utf-8')).toBe('NEW');
expect(fsSync.statSync(target).mode & 0o777).toBe(0o600);
});
it('atomicWriteFileSync: noFollow EXDEV fallback creates a new file when target does not exist', () => {
const target = path.join(tmpDir, 'never-created-sync.txt');
const exdevRename = () => {
const e: NodeJS.ErrnoException = new Error('EXDEV');
e.code = 'EXDEV';
throw e;
};
atomicWriteFileSync(
target,
'NEW',
{ noFollow: true, mode: 0o600 },
{ rename: exdevRename },
);
expect(fsSync.lstatSync(target).isSymbolicLink()).toBe(false);
expect(fsSync.readFileSync(target, 'utf-8')).toBe('NEW');
expect(fsSync.statSync(target).mode & 0o777).toBe(0o600);
});
});

View file

@ -242,13 +242,24 @@ export async function atomicWriteFile(
typeof data === 'string' ? Buffer.from(data, encoding) : data,
);
if (flush) await fd.sync();
// fchmod via the open fd — immune to symlink swap between
// close and a path-based chmod, which would otherwise redirect
// the 0o600 onto an attacker-pointed target and silently
// defeat noFollow on the EXDEV fallback path.
if (desiredMode !== undefined) {
try {
await fd.chmod(desiredMode);
} catch {
// Ignore — FAT/exFAT lack POSIX permissions.
}
}
} finally {
await fd.close();
}
} else {
await writeFileImpl(targetPath, data, writeOptions);
await tryChmod(targetPath);
}
await tryChmod(targetPath);
return;
} catch (fallbackError) {
// Preserve the function's error-shape contract even on the
@ -492,13 +503,21 @@ export function atomicWriteFileSync(
typeof data === 'string' ? Buffer.from(data, encoding) : data;
fsSync.writeSync(fd, buf);
if (flush) fsSync.fsyncSync(fd);
// fchmod on the open fd (see atomicWriteFile for rationale).
if (desiredMode !== undefined) {
try {
fsSync.fchmodSync(fd, desiredMode);
} catch {
// FAT/exFAT.
}
}
} finally {
fsSync.closeSync(fd);
}
} else {
writeFileImpl(targetPath, data, writeOptions);
tryChmodSync(targetPath);
}
tryChmodSync(targetPath);
return;
} catch (fallbackError) {
throw annotateWriteError(