feat(gateway): tool path hardening + sandbox escape prevention (P8-016) (#177)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #177.
This commit is contained in:
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
104
apps/gateway/src/agent/tools/path-guard.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { guardPath, guardPathUnsafe, SandboxEscapeError } from './path-guard.js';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
|
||||
describe('guardPathUnsafe', () => {
|
||||
const sandbox = '/tmp/test-sandbox';
|
||||
|
||||
it('allows paths inside sandbox', () => {
|
||||
const result = guardPathUnsafe('foo/bar.txt', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox, 'foo/bar.txt'));
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
const result = guardPathUnsafe('.', sandbox);
|
||||
expect(result).toBe(path.resolve(sandbox));
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../', () => {
|
||||
expect(() => guardPathUnsafe('../escape.txt', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
expect(() => guardPathUnsafe('/etc/passwd', sandbox)).toThrow(SandboxEscapeError);
|
||||
});
|
||||
|
||||
it('rejects deeply nested traversal', () => {
|
||||
expect(() => guardPathUnsafe('a/b/../../../../../../etc/passwd', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects path that starts with sandbox name but is sibling', () => {
|
||||
expect(() => guardPathUnsafe('/tmp/test-sandbox-evil/file.txt', sandbox)).toThrow(
|
||||
SandboxEscapeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the resolved absolute path for nested paths', () => {
|
||||
const result = guardPathUnsafe('deep/nested/file.ts', sandbox);
|
||||
expect(result).toBe('/tmp/test-sandbox/deep/nested/file.ts');
|
||||
});
|
||||
|
||||
it('SandboxEscapeError includes the user path and sandbox in message', () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
guardPathUnsafe('../escape.txt', sandbox);
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(SandboxEscapeError);
|
||||
const e = caught as SandboxEscapeError;
|
||||
expect(e.userPath).toBe('../escape.txt');
|
||||
expect(e.sandboxDir).toBe(sandbox);
|
||||
expect(e.message).toContain('Path escape attempt blocked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardPath', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
it('allows an existing path inside a real temp sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const subdir = path.join(tmpDir, 'subdir');
|
||||
fs.mkdirSync(subdir);
|
||||
const result = guardPath('subdir', tmpDir);
|
||||
expect(result).toBe(subdir);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows sandbox root itself', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
const result = guardPath('.', tmpDir);
|
||||
// realpathSync resolves the tmpdir symlinks (macOS /var -> /private/var)
|
||||
const realTmp = fs.realpathSync.native(tmpDir);
|
||||
expect(result).toBe(realTmp);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects path traversal with ../ on existing sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('../escape', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects absolute path outside sandbox', () => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-guard-test-'));
|
||||
try {
|
||||
expect(() => guardPath('/etc/passwd', tmpDir)).toThrow(SandboxEscapeError);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user