Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/__tests__/unit/files-security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/**
* Unit tests for file API path traversal security fixes.
*
* Run with: npx tsx src/__tests__/unit/files-security.test.ts
*
* Tests verify that:
* 1. isPathSafe correctly prevents path traversal attacks
* 2. Paths outside the base directory are rejected
* 3. Symlink-based escapes are caught
* 4. Edge cases (root, same path, trailing separators) are handled
*/

import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import path from 'path';
import os from 'os';
import fs from 'fs';

// Import the function under test
import { isPathSafe } from '../../lib/files';

describe('isPathSafe', () => {
it('should allow paths within the base directory', () => {
assert.equal(isPathSafe('/home/user/project', '/home/user/project/src/index.ts'), true);
assert.equal(isPathSafe('/home/user/project', '/home/user/project/package.json'), true);
assert.equal(isPathSafe('/home/user/project', '/home/user/project/src/lib/utils.ts'), true);
});

it('should allow the base directory itself', () => {
assert.equal(isPathSafe('/home/user/project', '/home/user/project'), true);
});

it('should reject paths outside the base directory', () => {
assert.equal(isPathSafe('/home/user/project', '/home/user/other'), false);
assert.equal(isPathSafe('/home/user/project', '/home/user'), false);
assert.equal(isPathSafe('/home/user/project', '/etc/passwd'), false);
assert.equal(isPathSafe('/home/user/project', '/tmp/malicious'), false);
});

it('should reject path traversal via ../', () => {
// path.resolve will normalize these, but the resolved path should be outside base
const base = '/home/user/project';
const traversal = path.resolve(base, '../../etc/passwd');
assert.equal(isPathSafe(base, traversal), false);
});

it('should reject directory names that are prefixes but not parents', () => {
// /home/user/project-evil should NOT be allowed under /home/user/project
assert.equal(isPathSafe('/home/user/project', '/home/user/project-evil/file.txt'), false);
assert.equal(isPathSafe('/home/user/project', '/home/user/projectx'), false);
});

it('should handle Windows-style paths if on Windows', () => {
if (process.platform === 'win32') {
assert.equal(isPathSafe('C:\\Users\\user\\project', 'C:\\Users\\user\\project\\src\\index.ts'), true);
assert.equal(isPathSafe('C:\\Users\\user\\project', 'D:\\other\\file.txt'), false);
}
});
});

describe('File API path traversal scenarios', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codepilot-test-'));
const projectDir = path.join(tmpDir, 'myproject');
const secretFile = path.join(tmpDir, 'secret.txt');

// Setup test fixtures
fs.mkdirSync(projectDir, { recursive: true });
fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(projectDir, 'index.ts'), 'console.log("hello");\n');
fs.writeFileSync(path.join(projectDir, 'src', 'app.ts'), 'export default {};\n');
fs.writeFileSync(secretFile, 'TOP SECRET DATA\n');

it('should allow reading files inside the project', () => {
const filePath = path.join(projectDir, 'index.ts');
assert.equal(isPathSafe(projectDir, filePath), true);
});

it('should allow reading files in subdirectories', () => {
const filePath = path.join(projectDir, 'src', 'app.ts');
assert.equal(isPathSafe(projectDir, filePath), true);
});

it('should block reading files outside the project via relative path', () => {
const maliciousPath = path.resolve(projectDir, '..', 'secret.txt');
assert.equal(isPathSafe(projectDir, maliciousPath), false);
// Verify the secret file actually exists (test is meaningful)
assert.equal(fs.existsSync(maliciousPath), true);
});

it('should block reading system files', () => {
assert.equal(isPathSafe(projectDir, '/etc/passwd'), false);
assert.equal(isPathSafe(projectDir, '/etc/shadow'), false);
});

it('should block reading via encoded traversal after resolution', () => {
// Even if someone tries URL-encoded ../, path.resolve normalizes it
const resolved = path.resolve(projectDir, '..', '..', 'etc', 'passwd');
assert.equal(isPathSafe(projectDir, resolved), false);
});

// Symlink test (only on Unix-like systems)
if (process.platform !== 'win32') {
it('should block symlink escape from project directory', () => {
const symlinkPath = path.join(projectDir, 'escape-link');
try {
fs.symlinkSync('/etc', symlinkPath);
const resolvedSymlink = fs.realpathSync(path.join(symlinkPath, 'passwd'));
assert.equal(isPathSafe(projectDir, resolvedSymlink), false);
} finally {
try { fs.unlinkSync(symlinkPath); } catch { /* cleanup */ }
}
});
}

// Cleanup test fixtures
after(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
});

describe('baseDir validation', () => {
it('should reject baseDir set to root (bypass attempt)', () => {
// If baseDir=/, every path would pass isPathSafe — must be blocked
const homeDir = os.homedir();
assert.equal(isPathSafe(homeDir, '/'), false);
});

it('should reject baseDir outside home directory', () => {
const homeDir = os.homedir();
assert.equal(isPathSafe(homeDir, '/tmp'), false);
assert.equal(isPathSafe(homeDir, '/etc'), false);
assert.equal(isPathSafe(homeDir, '/var/log'), false);
});

it('should allow baseDir inside home directory', () => {
const homeDir = os.homedir();
const projectDir = path.join(homeDir, 'projects', 'myapp');
assert.equal(isPathSafe(homeDir, projectDir), true);
});

it('should allow baseDir equal to home directory', () => {
const homeDir = os.homedir();
assert.equal(isPathSafe(homeDir, homeDir), true);
});

it('should block files outside home when no baseDir provided (fallback)', () => {
const homeDir = os.homedir();
assert.equal(isPathSafe(homeDir, '/etc/passwd'), false);
assert.equal(isPathSafe(homeDir, '/tmp/malicious'), false);
});

it('should allow files inside home when no baseDir provided (fallback)', () => {
const homeDir = os.homedir();
const filePath = path.join(homeDir, 'documents', 'file.txt');
assert.equal(isPathSafe(homeDir, filePath), true);
});
});
40 changes: 32 additions & 8 deletions src/app/api/files/preview/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,40 @@ export async function GET(request: NextRequest) {
}

const path = require('path');
const os = require('os');
const resolvedPath = path.resolve(filePath);
const homeDir = os.homedir();

// Basic safety: the file must exist under some reasonable base
// We check it resolves to an absolute path and is not a traversal attempt
const dir = path.dirname(resolvedPath);
if (!isPathSafe(dir, resolvedPath)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Invalid file path' },
{ status: 403 }
);
// Validate that the file is within the session's working directory.
// The baseDir parameter should be the session's working directory,
// which acts as the trust boundary for file access.
// The baseDir itself must be under the user's home directory to prevent
// attackers from setting baseDir=/ to bypass all restrictions.
const baseDir = searchParams.get('baseDir');
if (baseDir) {
const resolvedBase = path.resolve(baseDir);
// Ensure baseDir is within the home directory (prevent baseDir=/ bypass)
if (!isPathSafe(homeDir, resolvedBase)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Base directory is outside the allowed scope' },
{ status: 403 }
);
}
if (!isPathSafe(resolvedBase, resolvedPath)) {
return NextResponse.json<ErrorResponse>(
{ error: 'File is outside the project scope' },
{ status: 403 }
);
}
} else {
// Fallback: without a baseDir, restrict to the user's home directory
// to prevent reading arbitrary system files like /etc/passwd
if (!isPathSafe(homeDir, resolvedPath)) {
return NextResponse.json<ErrorResponse>(
{ error: 'File is outside the allowed scope' },
{ status: 403 }
);
}
}

try {
Expand Down
39 changes: 32 additions & 7 deletions src/app/api/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,41 @@ export async function GET(request: NextRequest) {
);
}

// Safety: resolve absolute path and validate
const path = require('path');
const os = require('os');
const resolvedDir = path.resolve(dir);
const homeDir = os.homedir();

// Only allow scanning within the provided directory
if (!isPathSafe(resolvedDir, resolvedDir)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Invalid directory path' },
{ status: 403 }
);
// Use baseDir (the session's working directory) as the trust boundary.
// The baseDir itself must be under the user's home directory to prevent
// attackers from setting baseDir=/ to bypass all restrictions.
// If no baseDir is provided, fall back to the user's home directory
// to prevent scanning arbitrary system directories.
const baseDir = searchParams.get('baseDir');
if (baseDir) {
const resolvedBase = path.resolve(baseDir);
// Ensure baseDir is within the home directory (prevent baseDir=/ bypass)
if (!isPathSafe(homeDir, resolvedBase)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Base directory is outside the allowed scope' },
{ status: 403 }
);
}
if (!isPathSafe(resolvedBase, resolvedDir)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Directory is outside the project scope' },
{ status: 403 }
);
}
} else {
// Fallback: without a baseDir, restrict to the user's home directory
// to prevent scanning arbitrary system directories like /etc
if (!isPathSafe(homeDir, resolvedDir)) {
return NextResponse.json<ErrorResponse>(
{ error: 'Directory is outside the allowed scope' },
{ status: 403 }
);
}
}

try {
Expand Down