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
- 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
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
- 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
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
- Invalid upload path
- Upload path too long
- Upload path must not contain traversal segments
- Uploaded folder has no usable name
- Upload must be a folder
AI-assisted analysis of abhigyanpatwari/GitNexus@aac7515d2a (2026-08-20).
Data as JSON: /api/errors/6be04c560560269f.
Report an issue: GitHub.