source dump of claude code
at main 176 lines 7.7 kB view raw
1/** 2 * Git can be weaponized for sandbox escape via two vectors: 3 * 1. Bare-repo attack: if cwd contains HEAD + objects/ + refs/ but no valid 4 * .git/HEAD, Git treats cwd as a bare repository and runs hooks from cwd. 5 * 2. Git-internal write + git: a compound command creates HEAD/objects/refs/ 6 * hooks/ then runs git — the git subcommand executes the freshly-created 7 * malicious hooks. 8 */ 9 10import { basename, posix, resolve, sep } from 'path' 11import { getCwd } from '../../utils/cwd.js' 12import { PS_TOKENIZER_DASH_CHARS } from '../../utils/powershell/parser.js' 13 14/** 15 * If a normalized path starts with `../<cwd-basename>/`, it re-enters cwd 16 * via the parent — resolve it to the cwd-relative form. posix.normalize 17 * preserves leading `..` (no cwd context), so `../project/hooks` with 18 * cwd=/x/project stays `../project/hooks` and misses the `hooks/` prefix 19 * match even though it resolves to the same directory at runtime. 20 * Check/use divergence: validator sees `../project/hooks`, PowerShell 21 * resolves against cwd to `hooks`. 22 */ 23function resolveCwdReentry(normalized: string): string { 24 if (!normalized.startsWith('../')) return normalized 25 const cwdBase = basename(getCwd()).toLowerCase() 26 if (!cwdBase) return normalized 27 // Iteratively strip `../<cwd-basename>/` pairs (handles `../../p/p/hooks` 28 // when cwd has repeated basename segments is unlikely, but one-level is 29 // the common attack). 30 const prefix = '../' + cwdBase + '/' 31 let s = normalized 32 while (s.startsWith(prefix)) { 33 s = s.slice(prefix.length) 34 } 35 // Also handle exact `../<cwd-basename>` (no trailing slash) 36 if (s === '../' + cwdBase) return '.' 37 return s 38} 39 40/** 41 * Normalize PS arg text → canonical path for git-internal matching. 42 * Order matters: structural strips first (colon-bound param, quotes, 43 * backtick escapes, provider prefix, drive-relative prefix), then NTFS 44 * per-component trailing-strip (spaces always; dots only if not `./..` 45 * after space-strip), then posix.normalize (resolves `..`, `.`, `//`), 46 * then case-fold. 47 */ 48function normalizeGitPathArg(arg: string): string { 49 let s = arg 50 // Normalize parameter prefixes: dash chars (–, —, ―) and forward-slash 51 // (PS 5.1). /Path:hooks/pre-commit → extract colon-bound value. (bug #28) 52 if (s.length > 0 && (PS_TOKENIZER_DASH_CHARS.has(s[0]!) || s[0] === '/')) { 53 const c = s.indexOf(':', 1) 54 if (c > 0) s = s.slice(c + 1) 55 } 56 s = s.replace(/^['"]|['"]$/g, '') 57 s = s.replace(/`/g, '') 58 // PS provider-qualified path: FileSystem::hooks/pre-commit → hooks/pre-commit 59 // Also handles fully-qualified form: Microsoft.PowerShell.Core\FileSystem::path 60 s = s.replace(/^(?:[A-Za-z0-9_.]+\\){0,3}FileSystem::/i, '') 61 // Drive-relative C:foo (no separator after colon) is cwd-relative on that 62 // drive. C:\foo (WITH separator) is absolute and must NOT match — the 63 // negative lookahead preserves it. 64 s = s.replace(/^[A-Za-z]:(?![/\\])/, '') 65 s = s.replace(/\\/g, '/') 66 // Win32 CreateFileW per-component: iteratively strip trailing spaces, 67 // then trailing dots, stopping if the result is `.` or `..` (special). 68 // `.. ` → `..`, `.. .` → `..`, `...` → '' → `.`, `hooks .` → `hooks`. 69 // Originally-'' (leading slash split) stays '' (absolute-path marker). 70 s = s 71 .split('/') 72 .map(c => { 73 if (c === '') return c 74 let prev 75 do { 76 prev = c 77 c = c.replace(/ +$/, '') 78 if (c === '.' || c === '..') return c 79 c = c.replace(/\.+$/, '') 80 } while (c !== prev) 81 return c || '.' 82 }) 83 .join('/') 84 s = posix.normalize(s) 85 if (s.startsWith('./')) s = s.slice(2) 86 return s.toLowerCase() 87} 88 89const GIT_INTERNAL_PREFIXES = ['head', 'objects', 'refs', 'hooks'] as const 90 91/** 92 * SECURITY: Resolve a normalized path that escapes cwd (leading `../` or 93 * absolute) against the actual cwd, then check if it lands back INSIDE cwd. 94 * If so, strip cwd and return the cwd-relative remainder for prefix matching. 95 * If it lands outside cwd, return null (genuinely external — path-validation's 96 * concern). Covers `..\<cwd-basename>\HEAD` and `C:\<full-cwd>\HEAD` which 97 * posix.normalize alone cannot resolve (it leaves leading `..` as-is). 98 * 99 * This is the SOLE guard for the bare-repo HEAD attack. path-validation's 100 * DANGEROUS_FILES deliberately excludes bare `HEAD` (false-positive risk 101 * on legitimate non-git files named HEAD) and DANGEROUS_DIRECTORIES 102 * matches per-segment `.git` only — so `<cwd>/HEAD` passes that layer. 103 * The cwd-resolution here is load-bearing; do not remove without adding 104 * an alternative guard. 105 */ 106function resolveEscapingPathToCwdRelative(n: string): string | null { 107 const cwd = getCwd() 108 // Reconstruct a platform-resolvable path from the posix-normalized form. 109 // `n` has forward slashes (normalizeGitPathArg converted \\ → /); resolve() 110 // handles forward slashes on Windows. 111 const abs = resolve(cwd, n) 112 const cwdWithSep = cwd.endsWith(sep) ? cwd : cwd + sep 113 // Case-insensitive comparison: normalizeGitPathArg lowercased `n`, so 114 // resolve() output has lowercase components from `n` but cwd may be 115 // mixed-case (e.g. C:\Users\...). Windows paths are case-insensitive. 116 const absLower = abs.toLowerCase() 117 const cwdLower = cwd.toLowerCase() 118 const cwdWithSepLower = cwdWithSep.toLowerCase() 119 if (absLower === cwdLower) return '.' 120 if (!absLower.startsWith(cwdWithSepLower)) return null 121 return abs.slice(cwdWithSep.length).replace(/\\/g, '/').toLowerCase() 122} 123 124function matchesGitInternalPrefix(n: string): boolean { 125 if (n === 'head' || n === '.git') return true 126 if (n.startsWith('.git/') || /^git~\d+($|\/)/.test(n)) return true 127 for (const p of GIT_INTERNAL_PREFIXES) { 128 if (p === 'head') continue 129 if (n === p || n.startsWith(p + '/')) return true 130 } 131 return false 132} 133 134/** 135 * True if arg (raw PS arg text) resolves to a git-internal path in cwd. 136 * Covers both bare-repo paths (hooks/, refs/) and standard-repo paths 137 * (.git/hooks/, .git/config). 138 */ 139export function isGitInternalPathPS(arg: string): boolean { 140 const n = resolveCwdReentry(normalizeGitPathArg(arg)) 141 if (matchesGitInternalPrefix(n)) return true 142 // SECURITY: leading `../` or absolute paths that resolveCwdReentry and 143 // posix.normalize couldn't fully resolve. Resolve against actual cwd — if 144 // the result lands back in cwd at a git-internal location, the guard must 145 // still fire. 146 if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) { 147 const rel = resolveEscapingPathToCwdRelative(n) 148 if (rel !== null && matchesGitInternalPrefix(rel)) return true 149 } 150 return false 151} 152 153/** 154 * True if arg resolves to a path inside .git/ (standard-repo metadata dir). 155 * Unlike isGitInternalPathPS, does NOT match bare-repo-style root-level 156 * `hooks/`, `refs/` etc. — those are common project directory names. 157 */ 158export function isDotGitPathPS(arg: string): boolean { 159 const n = resolveCwdReentry(normalizeGitPathArg(arg)) 160 if (matchesDotGitPrefix(n)) return true 161 // SECURITY: same cwd-resolution as isGitInternalPathPS — catch 162 // `..\<cwd-basename>\.git\hooks\pre-commit` that lands back in cwd. 163 if (n.startsWith('../') || n.startsWith('/') || /^[a-z]:/.test(n)) { 164 const rel = resolveEscapingPathToCwdRelative(n) 165 if (rel !== null && matchesDotGitPrefix(rel)) return true 166 } 167 return false 168} 169 170function matchesDotGitPrefix(n: string): boolean { 171 if (n === '.git' || n.startsWith('.git/')) return true 172 // NTFS 8.3 short names: .git becomes GIT~1 (or GIT~2, etc. if multiple 173 // dotfiles start with "git"). normalizeGitPathArg lowercases, so check 174 // for git~N as the first component. 175 return /^git~\d+($|\/)/.test(n) 176}