GitNexus/gitnexus/test/unit/upload-ingest.test.ts
SyedaAnshrahGillani dfe271b2a9
fix(core): ensure path prefix and traversal guards support root directories (#2559)
* fix(core): ensure path prefix and traversal guards support root directories

* chore(autofix): apply prettier + eslint fixes via /autofix command

* test(core): add test coverage for root-level and Windows drive-root paths

* fix(core): apply separator-aware prefix matching in augmentation engine

* chore(autofix): apply prettier + eslint fixes via /autofix command

---------

Co-authored-by: Syeda Anshrah Gillani <gillani@cloudment.io>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-20 08:12:15 +01:00

192 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import path from 'node:path';
import fs from 'node:fs/promises';
import os from 'node:os';
import { Readable } from 'node:stream';
import type { IncomingMessage } from 'node:http';
import {
resolveContainedDest,
ingestUpload,
DEFAULT_INGEST_LIMITS,
} from '../../src/server/upload-ingest.js';
import { STAGING_PREFIX } from '../../src/server/upload-paths.js';
// ── resolveContainedDest (pure sanitizer — the load-bearing control) ──────────
describe('resolveContainedDest', () => {
const ROOT = path.resolve('/tmp/gitnexus-sandbox');
it('contains a legitimate nested path under the root', () => {
expect(resolveContainedDest(ROOT, 'myrepo/src/index.js')).toBe(
path.join(ROOT, 'myrepo', 'src', 'index.js'),
);
});
it('allows spaces in names (regression: NUL check must not reject spaces)', () => {
expect(resolveContainedDest(ROOT, 'my repo/a b.js')).toBe(path.join(ROOT, 'my repo', 'a b.js'));
});
it.each([
['parent traversal', '../../etc/passwd'],
['mid traversal', 'a/../../b'],
['dot-dot segment', 'a/../b'],
['dot segment', 'a/./b'],
['absolute path', '/etc/shadow'],
['backslash', 'a\\b'],
['empty', ''],
])('rejects %s', (_label, rel) => {
expect(() => resolveContainedDest(ROOT, rel)).toThrow();
});
it('rejects a NUL byte', () => {
expect(() => resolveContainedDest(ROOT, `a${String.fromCharCode(0)}b/x.js`)).toThrow();
});
it('rejects non-string input', () => {
// @ts-expect-error testing runtime guard
expect(() => resolveContainedDest(ROOT, ['a', 'b'])).toThrow();
});
it('rejects an over-deep path (>64 segments)', () => {
const deep = Array.from({ length: 70 }, (_, i) => `d${i}`).join('/') + '/f.js';
expect(() => resolveContainedDest(ROOT, deep)).toThrow();
});
it('rejects an over-long path (>4096 chars)', () => {
const long = 'a/'.repeat(2100) + 'f.js';
expect(() => resolveContainedDest(ROOT, long)).toThrow();
});
it('rejects a sibling-prefix escape (root + sep, not bare startsWith)', () => {
// A rel that would resolve to a sibling dir sharing the root's string prefix.
expect(() => resolveContainedDest(ROOT, '../gitnexus-sandbox-evil/x.js')).toThrow();
});
if (process.platform === 'win32') {
it('handles Windows drive-root (e.g. "C:\\") correctly on Windows', () => {
const rootPath = 'C:\\';
const result = resolveContainedDest(rootPath, 'src/foo.ts');
expect(result).toBe('C:\\src\\foo.ts');
});
} else {
it('handles Unix root-level path roots (e.g. "/") correctly', () => {
const rootPath = '/';
const result = resolveContainedDest(rootPath, 'src/foo.ts');
expect(result).toBe('/src/foo.ts');
});
}
});
// ── ingestUpload (multipart streaming + containment + caps + cleanup) ──────────
const BOUNDARY = '----gitnexustestboundary';
function multipart(
parts: Array<{ name: string; value?: string; filename?: string; data?: Buffer }>,
): { body: Buffer; headers: Record<string, string> } {
const chunks: Buffer[] = [];
for (const p of parts) {
chunks.push(Buffer.from(`--${BOUNDARY}\r\n`));
if (p.filename !== undefined) {
chunks.push(
Buffer.from(
`Content-Disposition: form-data; name="${p.name}"; filename="${p.filename}"\r\n` +
`Content-Type: application/octet-stream\r\n\r\n`,
),
);
chunks.push(p.data ?? Buffer.alloc(0));
chunks.push(Buffer.from('\r\n'));
} else {
chunks.push(Buffer.from(`Content-Disposition: form-data; name="${p.name}"\r\n\r\n`));
chunks.push(Buffer.from(p.value ?? ''));
chunks.push(Buffer.from('\r\n'));
}
}
chunks.push(Buffer.from(`--${BOUNDARY}--\r\n`));
return {
body: Buffer.concat(chunks),
headers: { 'content-type': `multipart/form-data; boundary=${BOUNDARY}` },
};
}
function mockReq(body: Buffer, headers: Record<string, string>): IncomingMessage {
const r = Readable.from([body]) as unknown as IncomingMessage & { headers: typeof headers };
r.headers = headers;
return r;
}
describe('ingestUpload', () => {
// Each test gets its own staging parent (via the IngestOptions root override)
// so assertions never read the shared global ~/.gitnexus/uploads, which other
// upload test files mutate concurrently under vitest's parallel forks.
let root: string;
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ingest-test-'));
});
afterEach(async () => {
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
});
it('writes a manifest-described tree into the sandbox and returns its shape', async () => {
const { body, headers } = multipart([
{ name: 'manifest', value: JSON.stringify(['myrepo/a.js', 'myrepo/sub/b.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('alpha') },
{ name: 'files', filename: 'blob', data: Buffer.from('beta') },
]);
const result = await ingestUpload(mockReq(body, headers), undefined, { root });
expect(result.stageRoot.startsWith(await fs.realpath(root))).toBe(true);
expect(result.fileCount).toBe(2);
expect(result.topLevelName).toBe('myrepo');
expect(result.totalBytes).toBe('alpha'.length + 'beta'.length);
expect(await fs.readFile(path.join(result.stageRoot, 'myrepo', 'a.js'), 'utf8')).toBe('alpha');
expect(await fs.readFile(path.join(result.stageRoot, 'myrepo', 'sub', 'b.js'), 'utf8')).toBe(
'beta',
);
});
it('rejects a traversal path in the manifest and removes the staging dir', async () => {
const { body, headers } = multipart([
{ name: 'manifest', value: JSON.stringify(['../escape.js']) },
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
]);
await expect(ingestUpload(mockReq(body, headers), undefined, { root })).rejects.toMatchObject({
status: 400,
});
// The staging dir created by this call must not survive the rejection —
// the isolated root makes this exact (no concurrent test can add entries),
// and a missing/broken root throws rather than passing vacuously.
const entries = await fs.readdir(root);
expect(entries.filter((e) => e.startsWith(STAGING_PREFIX))).toEqual([]);
});
it('rejects a file part that arrives before the manifest', async () => {
const { body, headers } = multipart([
{ name: 'files', filename: 'blob', data: Buffer.from('x') },
{ name: 'manifest', value: JSON.stringify(['a/x.js']) },
]);
await expect(ingestUpload(mockReq(body, headers), undefined, { root })).rejects.toThrow(
/Manifest must precede/,
);
});
it('rejects when total bytes exceed the cap (413)', async () => {
const { body, headers } = multipart([
{ name: 'manifest', value: JSON.stringify(['r/big.bin']) },
{ name: 'files', filename: 'blob', data: Buffer.alloc(64, 1) },
]);
await expect(
ingestUpload(mockReq(body, headers), { maxTotalBytes: 16 }, { root }),
).rejects.toMatchObject({ status: 413 });
});
it('rejects an empty upload (0 files)', async () => {
const { body, headers } = multipart([{ name: 'manifest', value: JSON.stringify([]) }]);
await expect(ingestUpload(mockReq(body, headers), undefined, { root })).rejects.toThrow();
});
it('exposes sane default caps', () => {
expect(DEFAULT_INGEST_LIMITS.maxTotalBytes).toBe(250 * 1024 * 1024);
expect(DEFAULT_INGEST_LIMITS.maxFiles).toBe(20000);
});
});