diff --git a/nest/src/services/ipfs/ipfs.service.spec.ts b/nest/src/services/ipfs/ipfs.service.spec.ts index b27ccd0..ee47d7d 100644 --- a/nest/src/services/ipfs/ipfs.service.spec.ts +++ b/nest/src/services/ipfs/ipfs.service.spec.ts @@ -1,5 +1,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import axios from 'axios'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; import { IpfsService } from './ipfs.service'; jest.mock('axios'); @@ -7,6 +10,7 @@ const mockedAxios = axios as jest.Mocked; describe('IpfsService', () => { let service: IpfsService; + const tempDirs: string[] = []; beforeEach(async () => { process.env.IPFS_HOST = 'localhost'; @@ -23,6 +27,14 @@ describe('IpfsService', () => { jest.clearAllMocks(); }); + afterAll(async () => { + await Promise.all( + tempDirs.map((tempDir) => + fs.rm(tempDir, { recursive: true, force: true }), + ), + ); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); @@ -62,4 +74,71 @@ describe('IpfsService', () => { expect(result).toEqual([]); }); }); + + describe('addFolder safety', () => { + async function createTempRepo(): Promise<{ + tempDir: string; + repoDir: string; + outsideDir: string; + }> { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ipfs-service-')); + tempDirs.push(tempDir); + + const repoDir = path.join(tempDir, 'repo'); + const outsideDir = path.join(tempDir, 'outside'); + await fs.mkdir(path.join(repoDir, 'src'), { recursive: true }); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(repoDir, 'src', 'lib.rs'), 'pub fn ok() {}'); + + return { tempDir, repoDir, outsideDir }; + } + + it('should skip symlinked files while collecting IPFS files', async () => { + const { repoDir, outsideDir } = await createTempRepo(); + const outsideFile = path.join(outsideDir, 'env'); + await fs.writeFile(outsideFile, 'NEAR_MAINNET_PRIVATE_KEY=secret'); + await fs.symlink(outsideFile, path.join(repoDir, 'src', 'leak.env')); + + const files = await (service as any).getFilesRecursively( + await fs.realpath(repoDir), + ); + const relativeFiles = files.map((file: string) => + path.relative(repoDir, file), + ); + + expect(relativeFiles).toEqual(['src/lib.rs']); + }); + + it('should skip symlinked directories while collecting IPFS files', async () => { + const { repoDir, outsideDir } = await createTempRepo(); + await fs.writeFile( + path.join(outsideDir, 'secret.txt'), + 'NEAR_TESTNET_PRIVATE_KEY=secret', + ); + await fs.symlink(outsideDir, path.join(repoDir, 'linked-outside-dir')); + + const files = await (service as any).getFilesRecursively( + await fs.realpath(repoDir), + ); + const relativeFiles = files.map((file: string) => + path.relative(repoDir, file), + ); + + expect(relativeFiles).toEqual(['src/lib.rs']); + }); + + it('should reject resolved paths outside the IPFS root', () => { + const rootPath = path.resolve('/tmp/sourcescan-ipfs-root'); + + expect( + (service as any).isPathInsideRoot(rootPath, path.join(rootPath, 'a.rs')), + ).toBe(true); + expect( + (service as any).isPathInsideRoot( + rootPath, + path.resolve('/tmp/not-in-root/a.rs'), + ), + ).toBe(false); + }); + }); }); diff --git a/nest/src/services/ipfs/ipfs.service.ts b/nest/src/services/ipfs/ipfs.service.ts index 907c733..6abc6f6 100644 --- a/nest/src/services/ipfs/ipfs.service.ts +++ b/nest/src/services/ipfs/ipfs.service.ts @@ -28,10 +28,11 @@ export class IpfsService { this.logger.log(`Adding folder to IPFS from path: ${folderPath}`); const form = new FormData(); - const files = await this.getFilesRecursively(folderPath); + const rootPath = await fs.realpath(folderPath); + const files = await this.getFilesRecursively(rootPath); for (const file of files) { - const relativePath = path.relative(folderPath, file); + const relativePath = path.relative(rootPath, file); const content = await fs.readFile(file); form.append('file', content, { filename: relativePath, @@ -114,32 +115,53 @@ export class IpfsService { } } - private async getFilesRecursively(dir: string): Promise { + private async getFilesRecursively( + dir: string, + rootPath = dir, + ): Promise { const files: string[] = []; const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...(await this.getFilesRecursively(fullPath))); - } else if (entry.isSymbolicLink()) { - // Check if symlink points to a directory or file - try { - const stat = await fs.stat(fullPath); - if (stat.isDirectory()) { - files.push(...(await this.getFilesRecursively(fullPath))); - } else { - files.push(fullPath); - } - } catch { - // Skip broken symlinks - this.logger.warn(`Skipping broken symlink: ${fullPath}`); - } - } else { - files.push(fullPath); + const stats = await fs.lstat(fullPath); + + if (stats.isSymbolicLink()) { + this.logger.warn( + `Skipping symbolic link while adding to IPFS: ${fullPath}`, + ); + continue; + } + + if (stats.isDirectory()) { + files.push(...(await this.getFilesRecursively(fullPath, rootPath))); + continue; + } + + if (!stats.isFile()) { + this.logger.warn( + `Skipping non-regular file while adding to IPFS: ${fullPath}`, + ); + continue; } + + const realPath = await fs.realpath(fullPath); + if (!this.isPathInsideRoot(rootPath, realPath)) { + this.logger.warn(`Skipping file outside IPFS root: ${fullPath}`); + continue; + } + + files.push(realPath); } return files; } + + private isPathInsideRoot(rootPath: string, candidatePath: string): boolean { + const relativePath = path.relative(rootPath, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) + ); + } }