mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
21853b87d6
commit
7c476648e9
2 changed files with 65 additions and 2 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue