{"record":{"id":"6be04c560560269f","repo":"abhigyanpatwari/GitNexus","slug":"upload-path-escapes-the-sandbox","errorCode":null,"errorMessage":"Upload path escapes the sandbox","messagePattern":"Upload path escapes the sandbox","errorType":"http","errorClass":"BadRequestError","httpStatus":400,"severity":"error","filePath":"gitnexus/src/server/upload-ingest.ts","lineNumber":99,"sourceCode":"  const rawSegments = rel.split('/').filter((s) => s.length > 0);\n  if (rawSegments.length === 0 || rawSegments.length > MAX_PATH_DEPTH) {\n    throw new BadRequestError('Invalid upload path');\n  }\n  const segments: string[] = [];\n  for (const seg of rawSegments) {\n    // Normalize so NFC/NFD variants don't collide silently on case/unicode\n    // -folding filesystems (macOS/Windows).\n    const s = seg.normalize('NFC');\n    if (s === '.' || s === '..') {\n      throw new BadRequestError('Upload path must not contain traversal segments');\n    }\n    segments.push(s);\n  }\n  const dest = path.resolve(stageRoot, segments.join(path.sep));\n  // Suffix path.sep so a sibling prefix (/sandbox-evil vs /sandbox) can't pass.\n  const safePrefix = stageRoot.endsWith(path.sep) ? stageRoot : stageRoot + path.sep;\n  if (dest !== stageRoot && !dest.startsWith(safePrefix)) {\n    throw new BadRequestError('Upload path escapes the sandbox');\n  }\n  return dest;\n}\n\ninterface DirState {\n  dirCount: number;\n  limits: IngestLimits;\n}\n\n/**\n * Create the parent directories of `destFile` one segment at a time, asserting\n * after each `mkdir` that the segment is a real directory (not a symlink\n * swapped in mid-stream) still inside `stageRoot`. Counts created dirs against\n * the inode-exhaustion cap.\n */\nfunction mkdirContained(stageRoot: string, destFile: string, state: DirState): void {\n  const parent = path.dirname(destFile);\n  const relParent = path.relative(stageRoot, parent);","sourceCodeStart":81,"sourceCodeEnd":117,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/aac7515d2a8c50a1f8f923c6fb77218b333560d6/gitnexus/src/server/upload-ingest.ts#L81-L117","documentation":"The final containment check in resolveContainedDest: it resolves stageRoot + cleaned segments with path.resolve and requires the result to be stageRoot itself or begin with stageRoot + path.sep. The sep suffix matters — without it a sibling like /upload/sandbox-evil would prefix-match /upload/sandbox. Throwing 'Upload path escapes the sandbox' means the resolve-then-contain proof failed; in a correct call chain with clean segments this is unreachable, so hitting it indicates a normalization mismatch (e.g. stageRoot itself containing '..' or not being resolved).","triggerScenarios":"Calling resolveContainedDest with a stageRoot that is not absolute/canonical (contains '..', a trailing symlink component, or a different case on case-insensitive filesystems) so path.resolve output no longer prefix-matches; or direct misuse of the exported function with adversarial rel values in tests. Through the normal serve ingest flow, earlier checks make this throw practically unobservable.","commonSituations":"Unit tests exercising the exported guard directly with crafted rel values (expected throws — assert them); passing an mkdtemp path that was itself mutated between calls; on macOS/Windows, a stageRoot built via string concat that differs from the resolved form (case or separator).","solutions":["Always pass an mkdtemp-created, already-resolved stageRoot (the serve pipeline does exactly this) — never a hand-built relative path","In tests, treat this throw as the guard working: assert BadRequestError with 'Upload path escapes the sandbox' rather than 'succeeds'","If you call resolveContainedDest from your own code, path.resolve(stageRoot) once up front and reuse that canonical value everywhere"],"exampleFix":"// before — stageRoot not canonical\nconst dest = resolveContainedDest(path.join(root, 'stage', '..' ), rel); // mismatch → throws\n// after\nconst stageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-'));\nconst dest = resolveContainedDest(stageRoot, rel);","handlingStrategy":"validation","validationCode":"import path from 'path';\n// Canonicalize the sandbox root once, then check containment the same way the server does\nconst stageRoot = path.resolve(await fs.mkdtemp(path.join(os.tmpdir(), 'stage-')));\nconst safePrefix = stageRoot.endsWith(path.sep) ? stageRoot : stageRoot + path.sep;\nconst dest = path.resolve(stageRoot, rel);\nconst contained = dest === stageRoot || dest.startsWith(safePrefix);","typeGuard":"function isDestInsideRoot(dest: string, root: string): boolean {\n  const prefix = root.endsWith(path.sep) ? root : root + path.sep;\n  return dest === root || dest.startsWith(prefix);\n}","tryCatchPattern":"try { const dest = resolveContainedDest(stageRoot, rel); /* ... */ }\ncatch (err) {\n  if (err instanceof BadRequestError && err.message === 'Upload path escapes the sandbox') {\n    // guard fired: log and reject the part, never retry the same rel\n  } else throw err;\n}","preventionTips":["Only pass mkdtemp-created, already-resolved staging roots into path APIs","Mirror the resolve-then-contain idiom (root + path.sep prefix) in your own file-write code","In tests, assert the sibling-prefix case (/stage-evil vs /stage) is rejected — it is the exact bug the sep suffix prevents"],"tags":["upload","sandbox-escape","path-containment","validation","security-guard"],"backgroundTag":"upload-path-validation-failed","analyzedSha":"aac7515d2a8c50a1f8f923c6fb77218b333560d6","analyzedAt":"2026-08-20T23:29:22.980Z","schemaVersion":2},"datasetVersion":"2026-08-22T20:17:22.307Z"}