* Handle the ENOENT case: check if fullPath is a dangling symlink pointing outside * the worktree, or if it truly doesn't exist (in which case validate parent chain). * * Attack scenario this prevents: * - Repo contains `docs/config.yml` → symlink to `~/.ssh/some_new_file` (doesn't exist) * - r
( worktreePath: string, fullPath: string, )
| 222 | * @throws PathValidationError if symlink escapes worktree |
| 223 | */ |
| 224 | async function assertDanglingSymlinkSafe( |
| 225 | worktreePath: string, |
| 226 | fullPath: string, |
| 227 | ): Promise<void> { |
| 228 | const worktreeReal = await realpath(worktreePath); |
| 229 | |
| 230 | try { |
| 231 | // Check if the path itself exists (as a symlink or otherwise) |
| 232 | const stats = await lstat(fullPath); |
| 233 | |
| 234 | if (stats.isSymbolicLink()) { |
| 235 | // It's a dangling symlink - validate where it points |
| 236 | const linkTarget = await readlink(fullPath); |
| 237 | const resolvedTarget = isAbsolute(linkTarget) |
| 238 | ? linkTarget |
| 239 | : resolve(dirname(fullPath), linkTarget); |
| 240 | |
| 241 | // Check if the resolved target would be within worktree |
| 242 | // For dangling symlinks, we can't use realpath on the target, |
| 243 | // so we check the literal resolved path |
| 244 | const targetRelative = relative(worktreeReal, resolvedTarget); |
| 245 | if ( |
| 246 | targetRelative === ".." || |
| 247 | targetRelative.startsWith(`..${sep}`) || |
| 248 | isAbsolute(targetRelative) |
| 249 | ) { |
| 250 | throw new PathValidationError( |
| 251 | "Dangling symlink points outside the worktree", |
| 252 | "SYMLINK_ESCAPE", |
| 253 | ); |
| 254 | } |
| 255 | // Dangling symlink points within worktree - allow the operation |
| 256 | return; |
| 257 | } |
| 258 | |
| 259 | // Not a symlink but lstat succeeded - weird state, but validate parent chain |
| 260 | await assertParentInWorktree(worktreePath, fullPath); |
| 261 | } catch (error) { |
| 262 | if (error instanceof PathValidationError) { |
| 263 | throw error; |
| 264 | } |
| 265 | if (error instanceof Error && "code" in error && error.code === "ENOENT") { |
| 266 | // Path truly doesn't exist (not even as a symlink) - validate parent chain |
| 267 | await assertParentInWorktree(worktreePath, fullPath); |
| 268 | return; |
| 269 | } |
| 270 | // Other errors - fail closed |
| 271 | throw new PathValidationError("Cannot validate path", "SYMLINK_ESCAPE"); |
| 272 | } |
| 273 | } |
| 274 | export const secureFs = { |
| 275 | /** |
| 276 | * Read a file within a worktree. |
no test coverage detected