From 99a07e5580ff1667f714672ba48919cac1ac713d Mon Sep 17 00:00:00 2001 From: Wulan Ramadhani Date: Sat, 4 Jul 2026 15:42:34 +0800 Subject: [PATCH] harden(worktree): symlink-plant defense in pruneStale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add safeInsideWorktrees() check before fs.rmSync in pruneStale(). Uses fs.realpathSync.native to resolve symlinks and verifies the resolved path stays inside worktreeBase. Rejects any path that escapes via '..' or absolute resolution. Defends against: a pre-planted symlink inside .gstack-worktrees/ that redirects the rm target outside the worktree directory. *Submitted by 璇玑-58 via security audit* --- lib/worktree.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/worktree.ts b/lib/worktree.ts index 8854a8404..a2a293a2d 100644 --- a/lib/worktree.ts +++ b/lib/worktree.ts @@ -245,6 +245,18 @@ export class WorktreeManager { } catch { /* non-fatal */ } } + /** Verify a path resolves inside worktreeBase (defends against symlink-plant attacks). */ + private safeInsideWorktrees(p: string): boolean { + try { + const real = fs.realpathSync.native(p); + const baseReal = fs.realpathSync.native(this.worktreeBase); + const rel = path.relative(baseReal, real); + return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel); + } catch { + return false; + } + } + /** Remove worktrees from previous runs that weren't cleaned up. */ pruneStale(): void { try { @@ -264,6 +276,11 @@ export class WorktreeManager { const stat = fs.statSync(entryPath); const ageMs = Date.now() - stat.mtimeMs; if (ageMs < 3600_000) continue; + // Defend against symlink-plant: ensure it resolves inside worktreeBase + if (!this.safeInsideWorktrees(entryPath)) { + process.stderr.write(` WORKTREE: skip suspicious path: ${entryPath}\n`); + continue; + } fs.rmSync(entryPath, { recursive: true, force: true }); } catch { /* non-fatal */ } }