diff --git a/src/__tests__/cli.internal.test.js b/src/__tests__/cli.internal.test.js index 7cc8b4ff..57bf16d4 100644 --- a/src/__tests__/cli.internal.test.js +++ b/src/__tests__/cli.internal.test.js @@ -3681,6 +3681,161 @@ describe('profiler batch address parsing', () => { }); }); +// =================== profiler batch --file path traversal security =================== + +describe('profiler batch --file path traversal security', () => { + it('should reject path traversal with .. in file path', async () => { + const commands = buildCommands({}); + await expect( + commands['profiler'](['batch'], {}, {}, { + file: '../../../etc/passwd', + chain: 'ethereum', + delay: '0' + }) + ).rejects.toThrow('Invalid file path'); + }); + + it('should reject path traversal with ../ in file path', async () => { + const commands = buildCommands({}); + await expect( + commands['profiler'](['batch'], {}, {}, { + file: '../../sensitive/data.json', + chain: 'ethereum', + delay: '0' + }) + ).rejects.toThrow('Invalid file path'); + }); + + it('should reject path traversal with multiple .. sequences', async () => { + const commands = buildCommands({}); + await expect( + commands['profiler'](['batch'], {}, {}, { + file: 'data/../../../etc/passwd', + chain: 'ethereum', + delay: '0' + }) + ).rejects.toThrow('Invalid file path'); + }); + + it('should reject absolute paths on Unix-like systems', async () => { + const commands = buildCommands({}); + await expect( + commands['profiler'](['batch'], {}, {}, { + file: '/etc/passwd', + chain: 'ethereum', + delay: '0' + }) + ).rejects.toThrow('Invalid file path'); + }); + + it('should reject absolute paths with drive letters', async () => { + const commands = buildCommands({}); + // On Windows, path.isAbsolute will detect this; on Unix, it won't but the file won't exist anyway + // The security check is platform-aware via path.isAbsolute + const windowsPath = 'C:\\Windows\\System32\\config\\SAM'; + + // Only test if path.isAbsolute recognizes it as absolute (i.e., on Windows) + if (_path.isAbsolute(windowsPath)) { + await expect( + commands['profiler'](['batch'], {}, {}, { + file: windowsPath, + chain: 'ethereum', + delay: '0' + }) + ).rejects.toThrow('Invalid file path'); + } else { + // On Unix, this path is not recognized as absolute, so skip this specific test + // The Unix absolute path test above covers the security check on Unix systems + expect(true).toBe(true); + } + }); + + it('should accept valid relative file path without traversal', async () => { + const mockApi = { + addressLabels: vi.fn().mockResolvedValue({ labels: [] }), + addressBalance: vi.fn().mockResolvedValue({ balances: [] }), + }; + const commands = buildCommands({}); + + // Use a simple filename without path traversal + const testFile = 'test-addresses.json'; + fs.writeFileSync(testFile, JSON.stringify(['0x0000000000000000000000000000000000000001'])); + + try { + const result = await commands['profiler'](['batch'], mockApi, {}, { + file: testFile, + chain: 'ethereum', + delay: '0' + }); + + expect(result.total).toBe(1); + expect(mockApi.addressLabels).toHaveBeenCalledTimes(1); + } finally { + // Clean up + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + } + }); + + it('should read JSON array from file correctly', async () => { + const mockApi = { + addressLabels: vi.fn().mockResolvedValue({ labels: ['Fund'] }), + addressBalance: vi.fn().mockResolvedValue({ balances: [] }), + }; + const commands = buildCommands({}); + + const testFile = 'test-batch-addresses.json'; + fs.writeFileSync(testFile, JSON.stringify([ + '0x0000000000000000000000000000000000000001', + '0x0000000000000000000000000000000000000002' + ])); + + try { + const result = await commands['profiler'](['batch'], mockApi, {}, { + file: testFile, + chain: 'ethereum', + delay: '0' + }); + + expect(result.total).toBe(2); + expect(result.results).toHaveLength(2); + } finally { + // Clean up + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + } + }); + + it('should read newline-separated addresses from file when not valid JSON', async () => { + const mockApi = { + addressLabels: vi.fn().mockResolvedValue({ labels: [] }), + addressBalance: vi.fn().mockResolvedValue({ balances: [] }), + }; + const commands = buildCommands({}); + + const testFile = 'test-addresses.txt'; + fs.writeFileSync(testFile, '0x0000000000000000000000000000000000000001\n0x0000000000000000000000000000000000000002\n'); + + try { + const result = await commands['profiler'](['batch'], mockApi, {}, { + file: testFile, + chain: 'ethereum', + delay: '0' + }); + + expect(result.total).toBe(2); + } finally { + // Clean up + if (fs.existsSync(testFile)) { + fs.unlinkSync(testFile); + } + } + }); +}); + + // =================== profiler compare address parsing =================== describe('profiler compare address parsing', () => { diff --git a/src/cli.js b/src/cli.js index 3042f399..8ecb4d44 100644 --- a/src/cli.js +++ b/src/cli.js @@ -12,6 +12,7 @@ import { buildAgentCommands } from './commands/agent.js'; import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js'; import { resolveAddress, isEnsName } from './ens.js'; import fs from 'fs'; +import path from 'path'; import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js'; import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js'; import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js'; @@ -1153,6 +1154,9 @@ export function buildCommands(deps = {}) { if (options.addresses) { addresses = parseAddressList(options.addresses); } else if (options.file) { + if (options.file.includes('..') || path.isAbsolute(options.file)) { + throw new NansenError('Invalid file path', ErrorCode.INVALID_PARAMS); + } const content = fs.readFileSync(options.file, 'utf8'); try { const parsed = JSON.parse(content);