{"record":{"id":"d4ae058b45f99dce","repo":"stablyai/orca","slug":"access-denied-submodule-path-escapes-the-selected","errorCode":null,"errorMessage":"Access denied: submodule path escapes the selected worktree","messagePattern":"Access denied: submodule path escapes the selected worktree","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"src/main/git/status.ts","lineNumber":508,"sourceCode":"}\n\nfunction getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {\n  // Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host.\n  return `${options.wslDistro ?? 'native'}\\0${worktreePath}`\n}\n\n/**\n * Resolve a submodule's own worktree path from a parent worktree + relative\n * submodule path, rejecting anything that escapes the parent.\n */\nexport function resolveSubmoduleWorktreePath(worktreePath: string, submodulePath: string): string {\n  if (!submodulePath || submodulePath.includes('\\0') || path.isAbsolute(submodulePath)) {\n    throw new Error('Access denied: invalid submodule path')\n  }\n  const resolved = path.resolve(worktreePath, submodulePath)\n  const rel = path.relative(worktreePath, resolved)\n  if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {\n    throw new Error('Access denied: submodule path escapes the selected worktree')\n  }\n  return resolved\n}\n\n/**\n * Run a plain status inside a submodule's own worktree (lazy \"expand submodule\"\n * flow). Entry paths are relative to the submodule root; the renderer prefixes them.\n */\nexport async function getSubmoduleStatus(\n  worktreePath: string,\n  submodulePath: string,\n  options: GetStatusOptions & { staged?: boolean } = {}\n): Promise<GitStatusResult> {\n  const submoduleWorktreePath = resolveSubmoduleWorktreePath(worktreePath, submodulePath)\n  const limit = resolveGitStatusLimit(options.limit)\n  // Why: staged expansion only represents HEAD→index; scanning the submodule worktree is wasted work.\n  const workingResult = options.staged\n    ? ({ entries: [], conflictOperation: 'unknown' } satisfies GitStatusResult)","sourceCodeStart":490,"sourceCodeEnd":526,"githubUrl":"https://github.com/stablyai/orca/blob/1136503c6a231a16dce8f921f6fadb63d181e8db/src/main/git/status.ts#L490-L526","documentation":"Second guard in resolveSubmoduleWorktreePath: after path.resolve(worktreePath, submodulePath), the function computes path.relative(worktreePath, resolved) and throws if it is empty, '..', starts with '..<sep>', or is absolute. A legitimate submodule path stays inside the parent worktree; anything else means the resolved target escaped and must be refused to prevent filesystem access outside the selected worktree.","triggerScenarios":"Calling resolveSubmoduleWorktreePath with a submodulePath like '../outside', '../../etc', a symlink-laden worktreePath that resolves outside itself, or a crafted relative path that climbs above the parent. Also via getSubmoduleStatus and the diff loaders that route through it.","commonSituations":"A submodule path that legitimately tries to escape (rare, but possible from corrupted .gitmodules); a symlinked parent worktree whose realpath differs from the stored worktree path; adversarial automation passing traversal strings; Windows junction/MSYS edge cases where resolve() lands on a different drive.","solutions":["Use the parent worktree's actual realpath as worktreePath so resolve() and relative() agree on a root.","Source submodule paths from git submodule status / .gitmodules rather than constructing them yourself.","If the parent worktree is symlinked, resolve the symlink once at the boundary and pass the real path everywhere downstream.","Treat this throw as a hard stop — never sanitise the path to slip past it."],"exampleFix":"// before\nresolveSubmoduleWorktreePath(worktreePath, submodulePath) // worktreePath may be a symlink\n\n// after: resolve the parent root first\nimport { realpath } from 'node:fs/promises'\nconst realWorktree = await realpath(worktreePath)\nresolveSubmoduleWorktreePath(realWorktree, submodulePath)","handlingStrategy":"validation","validationCode":"import path from 'node:path'\nimport { realpath } from 'node:fs/promises'\n\nasync function submodulePathStaysInside(worktreePath: string, submodulePath: string): Promise<boolean> {\n  const root = await realpath(worktreePath)\n  const resolved = path.resolve(root, submodulePath)\n  const rel = path.relative(root, resolved)\n  return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)\n}","typeGuard":"function isSubmodulePathEscapes(error: unknown): boolean {\n  return error instanceof Error && error.message === 'Access denied: submodule path escapes the selected worktree'\n}","tryCatchPattern":"const realWorktree = await realpath(worktreePath)\nif (!(await submodulePathStaysInside(realWorktree, submodulePath))) {\n  throw new Error('Refuse to open submodule: resolved path escapes the worktree.')\n}\ntry { return resolveSubmoduleWorktreePath(realWorktree, submodulePath) }\ncatch (error) { if (isSubmodulePathEscapes(error)) { showStatus('Cannot open submodule outside the worktree.'); return null } throw error }","preventionTips":["realpath() the parent worktree once at the boundary and pass the real path downstream.","Source submodule paths from git's own listing, never construct them from raw user input.","Treat this throw as a hard stop; never sanitise a traversal path to slip past it."],"tags":["git","submodule","security","path-traversal","filesystem"],"backgroundTag":null,"analyzedSha":"1136503c6a231a16dce8f921f6fadb63d181e8db","analyzedAt":"2026-08-12T23:15:58.167Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}