(
projectRoot: string,
filePath: string,
options?: { allowSymlinkEscape?: boolean }
)
| 108 | * escapes the root |
| 109 | */ |
| 110 | export function validatePathWithinRoot( |
| 111 | projectRoot: string, |
| 112 | filePath: string, |
| 113 | options?: { allowSymlinkEscape?: boolean } |
| 114 | ): string | null { |
| 115 | const resolved = path.resolve(projectRoot, filePath); |
| 116 | const normalizedRoot = path.resolve(projectRoot); |
| 117 | |
| 118 | // 1. Lexical containment — cheap, catches `../` traversal. Applies even on |
| 119 | // the indexing read path: a crafted `../` escape is still rejected. |
| 120 | if (!isWithinDir(resolved, normalizedRoot)) { |
| 121 | return null; |
| 122 | } |
| 123 | |
| 124 | // 2. Symlink-aware containment — resolve symlinks on both sides and re-check, |
| 125 | // so an in-repo symlink whose real target escapes the root is rejected. |
| 126 | // The indexing read path (allowSymlinkEscape) skips only this rejection so |
| 127 | // it stays consistent with the directory walk, which already followed the |
| 128 | // in-root symlink to enumerate these files (#935). |
| 129 | try { |
| 130 | const realRoot = fs.realpathSync(normalizedRoot); |
| 131 | const realResolved = fs.realpathSync(resolved); |
| 132 | if (options?.allowSymlinkEscape) { |
| 133 | return realResolved; |
| 134 | } |
| 135 | return isWithinDir(realResolved, realRoot) ? realResolved : null; |
| 136 | } catch (err) { |
| 137 | // ENOENT: the path doesn't exist yet (a file about to be written, or an |
| 138 | // index entry for a since-deleted file) — no symlink to follow, and the |
| 139 | // lexical check already passed, so allow the lexical path. Any other |
| 140 | // resolution failure (ELOOP, EACCES, …) is treated as unsafe → reject. |
| 141 | if ((err as NodeJS.ErrnoException).code === 'ENOENT') { |
| 142 | return resolved; |
| 143 | } |
| 144 | return null; |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /** |
| 149 | * Validate that a path is a safe project root directory. |
no test coverage detected