Skip to content

Commit bd32bbb

Browse files
committed
fix(security): handle broken symlinks in validatePath to close write-outside-allowlist gap
When fs.realpath() fails with ENOENT, the previous fallback resolved the parent directory and rejoined the basename (the symlink name itself). Since fs.writeFile() follows symlinks, a broken symlink inside an allowed directory could redirect a write to an arbitrary path outside the allowlist. Fix: before falling back to parent-directory resolution, use fs.lstat() to detect whether the unreachable path is a symlink. If it is, read the symlink target with fs.readlink() and resolve it to an absolute canonical path so that the allowlist check and all subsequent file operations use the actual destination, not the symlink entry. Also tighten the ancestor-walk loop to stop on non-ENOENT errors (e.g. EPERM) rather than silently swallowing them, preventing a degraded-resolution bypass on restricted directory trees. Closes coderabbitai review comment on PR #398.
1 parent d29b716 commit bd32bbb

1 file changed

Lines changed: 50 additions & 21 deletions

File tree

src/tools/filesystem.ts

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -245,32 +245,61 @@ export async function validatePath(requestedPath: string): Promise<string> {
245245
throw new Error(`Failed to resolve symlink for path: ${absoluteOriginal}. Error: ${err.message}`);
246246
}
247247

248+
// SECURITY FIX: Check if the path is a broken symlink (symlink whose target
249+
// doesn't exist yet). fs.realpath() fails with ENOENT for broken symlinks, but
250+
// fs.lstat() succeeds because the symlink itself exists.
251+
// Without this check, the parent-directory fallback below would return the
252+
// symlink name rather than its target, allowing fs.writeFile() (which follows
253+
// symlinks) to write outside the allowlist.
254+
try {
255+
const lstat = await fs.lstat(absoluteOriginal);
256+
if (lstat.isSymbolicLink()) {
257+
// Read the raw symlink target and resolve it to an absolute path
258+
const linkTarget = await fs.readlink(absoluteOriginal);
259+
const resolvedTarget = path.isAbsolute(linkTarget)
260+
? path.resolve(linkTarget)
261+
: path.resolve(path.dirname(absoluteOriginal), linkTarget);
262+
// Use the resolved target as the canonical path for allowlist checking
263+
// and as the actual path for all subsequent file operations.
264+
resolvedRealPath = resolvedTarget;
265+
}
266+
} catch {
267+
// Not a symlink or unreadable; fall through to parent-directory resolution
268+
}
269+
248270
// SECURITY FIX: When the full path doesn't exist (e.g., writing a new file),
249271
// resolve the parent directory to detect symlinks in the path chain.
250272
// Without this, an attacker could create a symlink inside an allowed directory
251273
// pointing to a restricted location, then write to a non-existent file through
252274
// that symlink — bypassing the directory restriction check.
253-
try {
254-
const parentDir = path.dirname(absoluteOriginal);
255-
const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' });
256-
const basename = path.basename(absoluteOriginal);
257-
resolvedRealPath = path.join(resolvedParent, basename);
258-
} catch {
259-
// Parent also doesn't exist — walk up the tree to find
260-
// the deepest existing ancestor and resolve it
261-
let current = absoluteOriginal;
262-
let remaining: string[] = [];
263-
while (true) {
264-
const parent = path.dirname(current);
265-
if (parent === current) break; // reached filesystem root
266-
remaining.unshift(path.basename(current));
267-
current = parent;
268-
try {
269-
const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' });
270-
resolvedRealPath = path.join(resolvedAncestor, ...remaining);
271-
break;
272-
} catch {
273-
// keep walking up
275+
if (resolvedRealPath === null) {
276+
try {
277+
const parentDir = path.dirname(absoluteOriginal);
278+
const resolvedParent = await fs.realpath(parentDir, { encoding: 'utf8' });
279+
const basename = path.basename(absoluteOriginal);
280+
resolvedRealPath = path.join(resolvedParent, basename);
281+
} catch {
282+
// Parent also doesn't exist — walk up the tree to find
283+
// the deepest existing ancestor and resolve it
284+
let current = absoluteOriginal;
285+
let remaining: string[] = [];
286+
while (true) {
287+
const parent = path.dirname(current);
288+
if (parent === current) break; // reached filesystem root
289+
remaining.unshift(path.basename(current));
290+
current = parent;
291+
try {
292+
const resolvedAncestor = await fs.realpath(current, { encoding: 'utf8' });
293+
resolvedRealPath = path.join(resolvedAncestor, ...remaining);
294+
break;
295+
} catch (walkErr) {
296+
const walkError = walkErr as NodeJS.ErrnoException;
297+
// Non-ENOENT error (e.g., EPERM) means we cannot safely
298+
// resolve this ancestor — stop walking to avoid returning
299+
// an unresolved path that could bypass allowlist checks.
300+
if (walkError.code && walkError.code !== 'ENOENT') break;
301+
// ENOENT: keep walking up
302+
}
274303
}
275304
}
276305
}

0 commit comments

Comments
 (0)