harden(worktree): symlink-plant defense in pruneStale

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*
This commit is contained in:
Wulan Ramadhani 2026-07-04 15:42:34 +08:00
parent 11de390be1
commit 99a07e5580
1 changed files with 17 additions and 0 deletions

View File

@ -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 */ }
}