abhigyanpatwari/GitNexus · error · BadRequestError

Upload path escapes the sandbox

Error message

Upload path escapes the sandbox

What it means

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).

Source

Thrown at gitnexus/src/server/upload-ingest.ts:99

  const rawSegments = rel.split('/').filter((s) => s.length > 0);
  if (rawSegments.length === 0 || rawSegments.length > MAX_PATH_DEPTH) {
    throw new BadRequestError('Invalid upload path');
  }
  const segments: string[] = [];
  for (const seg of rawSegments) {
    // Normalize so NFC/NFD variants don't collide silently on case/unicode
    // -folding filesystems (macOS/Windows).
    const s = seg.normalize('NFC');
    if (s === '.' || s === '..') {
      throw new BadRequestError('Upload path must not contain traversal segments');
    }
    segments.push(s);
  }
  const dest = path.resolve(stageRoot, segments.join(path.sep));
  // Suffix path.sep so a sibling prefix (/sandbox-evil vs /sandbox) can't pass.
  const safePrefix = stageRoot.endsWith(path.sep) ? stageRoot : stageRoot + path.sep;
  if (dest !== stageRoot && !dest.startsWith(safePrefix)) {
    throw new BadRequestError('Upload path escapes the sandbox');
  }
  return dest;
}

interface DirState {
  dirCount: number;
  limits: IngestLimits;
}

/**
 * Create the parent directories of `destFile` one segment at a time, asserting
 * after each `mkdir` that the segment is a real directory (not a symlink
 * swapped in mid-stream) still inside `stageRoot`. Counts created dirs against
 * the inode-exhaustion cap.
 */
function mkdirContained(stageRoot: string, destFile: string, state: DirState): void {
  const parent = path.dirname(destFile);
  const relParent = path.relative(stageRoot, parent);

View on GitHub (pinned to aac7515d2a)

Solutions

  1. Always pass an mkdtemp-created, already-resolved stageRoot (the serve pipeline does exactly this) — never a hand-built relative path
  2. In tests, treat this throw as the guard working: assert BadRequestError with 'Upload path escapes the sandbox' rather than 'succeeds'
  3. If you call resolveContainedDest from your own code, path.resolve(stageRoot) once up front and reuse that canonical value everywhere

Example fix

// before — stageRoot not canonical
const dest = resolveContainedDest(path.join(root, 'stage', '..' ), rel); // mismatch → throws
// after
const stageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-'));
const dest = resolveContainedDest(stageRoot, rel);
Defensive patterns

Strategy: validation

Validate before calling

import path from 'path';
// Canonicalize the sandbox root once, then check containment the same way the server does
const stageRoot = path.resolve(await fs.mkdtemp(path.join(os.tmpdir(), 'stage-')));
const safePrefix = stageRoot.endsWith(path.sep) ? stageRoot : stageRoot + path.sep;
const dest = path.resolve(stageRoot, rel);
const contained = dest === stageRoot || dest.startsWith(safePrefix);

Type guard

function isDestInsideRoot(dest: string, root: string): boolean {
  const prefix = root.endsWith(path.sep) ? root : root + path.sep;
  return dest === root || dest.startsWith(prefix);
}

Try / catch

try { const dest = resolveContainedDest(stageRoot, rel); /* ... */ }
catch (err) {
  if (err instanceof BadRequestError && err.message === 'Upload path escapes the sandbox') {
    // guard fired: log and reject the part, never retry the same rel
  } else throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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).

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@aac7515d2a (2026-08-20). Data as JSON: /api/errors/6be04c560560269f. Report an issue: GitHub.