fix(security): validateOutputPath symlink bypass — check file-level symlinks

validateOutputPath() previously only resolved symlinks on the parent directory.
A symlink at /tmp/evil.png → /etc/crontab passed the parent check (parent is
/tmp, which is safe) but the write followed the symlink outside safe dirs.

Add lstatSync() check: if the target file exists and is a symlink, resolve
through it and verify the real target is within SAFE_DIRECTORIES. ENOENT
(file doesn't exist yet) falls through to the existing parent-dir check.

Closes #921

Co-Authored-By: Yunsu <Hybirdss@users.noreply.github.com>
This commit is contained in:
Garry Tan
2026-04-13 09:32:38 -07:00
parent c6e6a21d1a
commit 4b9fb4a3db
+20 -1
View File
@@ -33,7 +33,26 @@ const TEMP_ONLY = [TEMP_DIR].map(d => {
export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
// Resolve real path of the parent directory to catch symlinks.
// If the target already exists and is a symlink, resolve through it.
// Without this, a symlink at /tmp/evil.png → /etc/crontab passes the
// parent-directory check (parent is /tmp, which is safe) but the actual
// write follows the symlink to /etc/crontab.
try {
const stat = fs.lstatSync(resolved);
if (stat.isSymbolicLink()) {
const realTarget = fs.realpathSync(resolved);
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
return; // symlink target verified, no need to check parent
}
} catch (e: any) {
// ENOENT = file doesn't exist yet, fall through to parent-dir check
if (e.code !== 'ENOENT') throw e;
}
// For new files (no existing symlink), verify the parent directory.
// The file itself may not exist yet (e.g., screenshot output).
// This also handles macOS /tmp → /private/tmp transparently.
let dir = path.dirname(resolved);