From 854ba6ec749204377d0fa8c7d7b9948e6feefbf3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 23:22:08 -0800 Subject: [PATCH] fix: Add symlink validation to prevent path traversal attacks --- .../routes/worktree/routes/discard-changes.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 966351ee..07af30fe 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -20,20 +20,39 @@ import type { Request, Response } from 'express'; import { execFile } from 'child_process'; import { promisify } from 'util'; import * as path from 'path'; +import * as fs from 'fs'; import { getErrorMessage, logError } from '../common.js'; const execFileAsync = promisify(execFile); /** * Validate that a file path does not escape the worktree directory. - * Prevents path traversal attacks (e.g., ../../etc/passwd). + * Prevents path traversal attacks (e.g., ../../etc/passwd) and + * rejects symlinks inside the worktree that point outside of it. */ function validateFilePath(filePath: string, worktreePath: string): boolean { - // Resolve the full path relative to the worktree + // Resolve the full path relative to the worktree (lexical resolution) const resolved = path.resolve(worktreePath, filePath); const normalizedWorktree = path.resolve(worktreePath); - // Ensure the resolved path starts with the worktree path - return resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree; + + // First, perform lexical prefix check + const lexicalOk = + resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree; + if (!lexicalOk) { + return false; + } + + // Then, attempt symlink-aware validation using realpath. + // This catches symlinks inside the worktree that point outside of it. + try { + const realResolved = fs.realpathSync(resolved); + const realWorktree = fs.realpathSync(normalizedWorktree); + return realResolved.startsWith(realWorktree + path.sep) || realResolved === realWorktree; + } catch { + // If realpath fails (e.g., target doesn't exist yet for untracked files), + // fall back to the lexical startsWith check which already passed above. + return true; + } } export function createDiscardChangesHandler() {