diff --git a/apps/backend/README.md b/apps/backend/README.md index 8a9312c..19bfb8a 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -92,8 +92,9 @@ The server will start on port 3000 by default (configurable via environment vari | Endpoint | Method | Description | Request Body | Response | |----------|--------|-------------|--------------|----------| | `/api/compile` | POST | Compiles Rust code to WASM | `{ code: string }` | `{ success: boolean, output: string, error?: string }` | -| `/api/test` | POST | Runs tests for Rust code | `{ code: string }` | `{ success: boolean, output: string, error?: string }` | | `/api/health` | GET | Server health check | None | `{ status: "ok" }` | +| `/api/test` | POST | Runs tests for Rust code | `{ code: string }` | `{ success: boolean, output: string, error?: string }` | +| `/api/test-filemanager` | POST | Test fileManager utilities | `{ baseName?: string, rustCode?: string }` | `{ success: boolean, sanitizedName: string, tempDir: string, message: string }` | ## Security Measures @@ -130,7 +131,7 @@ backend/ │ │ ├── compile.controller.ts │ │ └── test.controller.ts │ ├── utils/ -│ │ ├── file.utils.ts +│ │ ├── fileManager.ts │ │ └── process.utils.ts │ ├── services/ │ │ ├── compilation.service.ts diff --git a/apps/backend/bun.lock b/apps/backend/bun.lock index 0635f46..167e10a 100644 --- a/apps/backend/bun.lock +++ b/apps/backend/bun.lock @@ -24,6 +24,9 @@ "typescript": "5.8.3", "typescript-eslint": "^8.30.0", }, + "peerDependencies": { + "typescript": "^5.8.3", + }, }, }, "packages": { diff --git a/apps/backend/package.json b/apps/backend/package.json index 8b26c70..609b209 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -28,7 +28,7 @@ "private": true, "scripts": { "dev": "bun run --watch src/index.ts", - "build": "bun build src/index.ts --outdir ./dist", + "build": "bun build src/index.ts --outdir ./dist --target node", "lint": "bun eslint src/**/*.ts", "format": "bun prettier --write 'src/**/*.ts'" }, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index f9426d7..663963d 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,6 +1,48 @@ import express from 'express'; +import { setupProject, getSanitizedDirName, createRustProject } from './utils/fileManager'; + const app = express(); +app.use(express.json()); + app.get('/', (_, res) => res.send('Hello from Backend!' + '
' + 'The best online soroban compiler is coming...') ); + +// Test endpoint for fileManager functionality +app.post('/api/test-filemanager', async (req, res) => { + try { + const { + baseName = 'test-project', + rustCode = 'pub fn hello() -> &\'static str { "Hello, Soroban!" }', + } = req.body; + + // Test sanitization + const sanitized = getSanitizedDirName(baseName); + + // Test project setup + const project = await setupProject({ baseName }); + + // Test Rust project creation + await createRustProject(project.tempDir, rustCode); + + // Success response + const response = { + success: true, + sanitizedName: sanitized, + tempDir: project.tempDir, + message: 'FileManager test completed successfully - Rust project created and cleaned up', + }; + + // Cleanup + await project.cleanup(); + + res.json(response); + } catch (error) { + res.status(500).json({ + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +}); + app.listen(3000, () => console.log('Server on http://localhost:3000')); diff --git a/apps/backend/src/utils/fileManager.test.ts b/apps/backend/src/utils/fileManager.test.ts new file mode 100644 index 0000000..c4d4899 --- /dev/null +++ b/apps/backend/src/utils/fileManager.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, afterEach } from 'bun:test'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + setupProject, + cleanupProject, + getSanitizedDirName, + createRustProject, +} from './fileManager'; + +describe('fileManager Security Tests', () => { + let testDirs: string[] = []; + + afterEach(async () => { + // Clean up any test directories + for (const dir of testDirs) { + try { + await fs.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + testDirs = []; + }); + + describe('getSanitizedDirName', () => { + it('should sanitize path traversal attempts', () => { + expect(getSanitizedDirName('../malicious')).toBe('malicious'); + expect(getSanitizedDirName('../../etc/passwd')).toBe('etc_passwd'); + expect(getSanitizedDirName('../../../root')).toBe('root'); + expect(getSanitizedDirName('..\\..\\windows')).toBe('windows'); + }); + + it('should handle Windows reserved filenames', () => { + const windowsReserved = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'LPT1', 'LPT2']; + + for (const reserved of windowsReserved) { + const result = getSanitizedDirName(reserved); + expect(result).not.toBe(reserved.toLowerCase()); + expect(result).not.toBe(reserved.toUpperCase()); + // Should return 'project' as fallback for reserved names + expect(result).toBe('project'); + } + }); + + it('should handle dangerous characters', () => { + const dangerousChars = '<>:"/\\\\|?*'; + const result = getSanitizedDirName(`test${dangerousChars}name`); + + // Should not contain any of the dangerous characters + for (const char of dangerousChars) { + expect(result).not.toContain(char); + } + + expect(result).toContain('test'); + expect(result).toContain('name'); + }); + + it('should handle unicode and emoji', () => { + expect(getSanitizedDirName('test🚀project')).toBeTruthy(); + expect(getSanitizedDirName('tëst-prøjéct')).toBeTruthy(); + expect(getSanitizedDirName('测试项目')).toBeTruthy(); + }); + + it('should handle long filenames', () => { + const longName = 'a'.repeat(300); + const result = getSanitizedDirName(longName); + + expect(result.length).toBeLessThanOrEqual(255); + expect(result.length).toBeGreaterThan(0); + }); + + it('should handle empty and whitespace inputs', () => { + expect(getSanitizedDirName('')).toBe(''); + expect(getSanitizedDirName(' ')).toBe(''); + expect(getSanitizedDirName('\t\n\r')).toBe(''); + }); + + it('should handle null and undefined inputs', () => { + expect(getSanitizedDirName(null as unknown as string)).toBe(''); + expect(getSanitizedDirName(undefined as unknown as string)).toBe(''); + expect(getSanitizedDirName(123 as unknown as string)).toBe(''); + }); + + it('should preserve valid directory names', () => { + expect(getSanitizedDirName('valid-project')).toBe('valid-project'); + expect(getSanitizedDirName('my_contract_v1')).toBe('my_contract_v1'); + expect(getSanitizedDirName('Project123')).toBe('Project123'); + }); + }); + + describe('setupProject', () => { + it('should create unique directories with sanitized names', async () => { + const project1 = await setupProject({ baseName: '../malicious' }); + const project2 = await setupProject({ baseName: '../malicious' }); + + testDirs.push(project1.tempDir, project2.tempDir); + + // Should create different directories even with same base name + expect(project1.tempDir).not.toBe(project2.tempDir); + + // Should not contain path traversal + expect(project1.tempDir).not.toContain('../'); + expect(project2.tempDir).not.toContain('../'); + + // Should be in system temp directory + expect(project1.tempDir.startsWith(tmpdir())).toBe(true); + expect(project2.tempDir.startsWith(tmpdir())).toBe(true); + + // Directories should exist + const stats1 = await fs.stat(project1.tempDir); + const stats2 = await fs.stat(project2.tempDir); + expect(stats1.isDirectory()).toBe(true); + expect(stats2.isDirectory()).toBe(true); + + // Cleanup + await project1.cleanup(); + await project2.cleanup(); + }); + + it('should handle Windows reserved names safely', async () => { + const project = await setupProject({ baseName: 'CON' }); + testDirs.push(project.tempDir); + + // Should not contain 'CON' as directory name + const dirName = project.tempDir.split(/[/\\]/).pop() || ''; + expect(dirName.toLowerCase()).not.toBe('con'); + + // Should still create a valid directory + const stats = await fs.stat(project.tempDir); + expect(stats.isDirectory()).toBe(true); + + await project.cleanup(); + }); + + it('should create directories with fallback names for empty inputs', async () => { + const project = await setupProject({ baseName: '' }); + testDirs.push(project.tempDir); + + // Should create a directory even with empty base name + const stats = await fs.stat(project.tempDir); + expect(stats.isDirectory()).toBe(true); + + // Directory name should contain 'project' as fallback + const dirName = project.tempDir.split(/[/\\]/).pop() || ''; + expect(dirName).toContain('project'); + + await project.cleanup(); + }); + + it('should prevent directory creation outside temp folder with custom tempRoot', async () => { + // This should work - using a subdirectory of temp + const customTemp = join(tmpdir(), 'custom-temp'); + await fs.mkdir(customTemp, { recursive: true }); + testDirs.push(customTemp); + + const project = await setupProject({ + baseName: 'test', + tempRoot: customTemp, + }); + testDirs.push(project.tempDir); + + expect(project.tempDir.startsWith(customTemp)).toBe(true); + + const stats = await fs.stat(project.tempDir); + expect(stats.isDirectory()).toBe(true); + + await project.cleanup(); + }); + }); + + describe('cleanupProject', () => { + it('should safely remove project directories', async () => { + const project = await setupProject({ baseName: 'cleanup-test' }); + + // Verify directory exists + const stats = await fs.stat(project.tempDir); + expect(stats.isDirectory()).toBe(true); + + // Create some files in the directory + await fs.writeFile(join(project.tempDir, 'test.txt'), 'test content'); + await fs.mkdir(join(project.tempDir, 'subdir')); + await fs.writeFile(join(project.tempDir, 'subdir', 'nested.txt'), 'nested content'); + + // Cleanup should remove everything + await cleanupProject(project.tempDir); + + // Directory should no longer exist + await expect(fs.stat(project.tempDir)).rejects.toThrow(); + }); + + it('should refuse to clean directories outside temp folder', async () => { + // Try to clean a directory outside temp + const maliciousPath = '/etc/passwd'; + + await expect(cleanupProject(maliciousPath)).rejects.toThrow( + 'Refusing to clean directory outside temp folder' + ); + }); + + it('should handle non-existent directories gracefully', async () => { + const nonExistentPath = join(tmpdir(), 'non-existent-dir-12345'); + + // Should not throw error for non-existent directory + await expect(cleanupProject(nonExistentPath)).resolves.toBeUndefined(); + }); + + it('should validate input parameters', async () => { + await expect(cleanupProject('')).rejects.toThrow('Invalid tempDir provided'); + await expect(cleanupProject(null as unknown as string)).rejects.toThrow( + 'Invalid tempDir provided' + ); + await expect(cleanupProject(undefined as unknown as string)).rejects.toThrow( + 'Invalid tempDir provided' + ); + }); + }); + + describe('createRustProject', () => { + it('should create valid Rust project structure', async () => { + const project = await setupProject({ baseName: 'rust-test' }); + testDirs.push(project.tempDir); + + const rustCode = ` +use soroban_sdk::{contract, contractimpl}; + +#[contract] +pub struct HelloContract; + +#[contractimpl] +impl HelloContract { + pub fn hello() -> &'static str { + "Hello, Soroban!" + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_hello() { + assert_eq!(HelloContract::hello(), "Hello, Soroban!"); + } +} + `.trim(); + + await createRustProject(project.tempDir, rustCode); + + // Verify Cargo.toml exists and contains expected content + const cargoToml = await fs.readFile(join(project.tempDir, 'Cargo.toml'), 'utf8'); + expect(cargoToml).toContain('[package]'); + expect(cargoToml).toContain('soroban-sdk'); + expect(cargoToml).toContain('crate-type = ["cdylib"]'); + + // Verify lib.rs exists with the provided code + const libRs = await fs.readFile(join(project.tempDir, 'src', 'lib.rs'), 'utf8'); + expect(libRs).toBe(rustCode); + + // Verify src directory structure + const srcStats = await fs.stat(join(project.tempDir, 'src')); + expect(srcStats.isDirectory()).toBe(true); + + await project.cleanup(); + }); + + it('should validate input parameters', async () => { + const project = await setupProject(); + testDirs.push(project.tempDir); + + await expect(createRustProject('', 'code')).rejects.toThrow('Invalid tempDir provided'); + await expect(createRustProject(project.tempDir, '')).rejects.toThrow( + 'Invalid rustCode provided' + ); + await expect(createRustProject(project.tempDir, null as unknown as string)).rejects.toThrow( + 'Invalid rustCode provided' + ); + + await project.cleanup(); + }); + }); + + describe('Integration tests', () => { + it('should handle complete workflow with malicious inputs', async () => { + // Test complete workflow with various malicious inputs + const maliciousInputs = [ + '../../../malicious', + 'CON.txt', + 'test', + '../../../../etc/passwd', + 'very'.repeat(10), // Long name (reduced to avoid filesystem limits) + ]; + + for (const maliciousInput of maliciousInputs) { + const project = await setupProject({ baseName: maliciousInput }); + testDirs.push(project.tempDir); + + // Should create safe directory + expect(project.tempDir.startsWith(tmpdir())).toBe(true); + expect(project.tempDir).not.toContain('../'); + + // Should be able to create Rust project + const rustCode = 'pub fn hello() -> &\'static str { "Hello" }'; + await createRustProject(project.tempDir, rustCode); + + // Files should exist + const stats = await fs.stat(join(project.tempDir, 'Cargo.toml')); + expect(stats.isFile()).toBe(true); + + // Cleanup should work + await project.cleanup(); + + // Directory should be gone + await expect(fs.stat(project.tempDir)).rejects.toThrow(); + } + }); + }); +}); diff --git a/apps/backend/src/utils/fileManager.ts b/apps/backend/src/utils/fileManager.ts new file mode 100644 index 0000000..45d427b --- /dev/null +++ b/apps/backend/src/utils/fileManager.ts @@ -0,0 +1,230 @@ +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomBytes } from 'node:crypto'; +import sanitizeFilename from 'sanitize-filename'; + +/** + * Configuration interface for project setup + */ +export interface ProjectSetup { + /** Absolute path to the temporary directory */ + tempDir: string; + /** Function to clean up the temporary directory */ + cleanup: () => Promise; +} + +/** + * Options for project setup + */ +export interface ProjectSetupOptions { + /** Base name for the project directory (will be sanitized) */ + baseName?: string; + /** Custom temporary directory root (defaults to OS temp dir) */ + tempRoot?: string; +} + +/** + * Sanitizes a directory name to prevent path traversal and ensure cross-platform compatibility + * + * @param baseName - The base name to sanitize + * @returns A sanitized directory name safe for use across platforms + * + * @example + * ```typescript + * getSanitizedDirName('../malicious') // returns 'malicious' + * getSanitizedDirName('CON') // returns '' (Windows reserved name) + * getSanitizedDirName('my-project') // returns 'my-project' + * ``` + */ +export function getSanitizedDirName(baseName: string): string { + if (!baseName || typeof baseName !== 'string') { + return ''; + } + + const trimmed = baseName.trim(); + + // Handle whitespace-only strings + if (!trimmed) { + return ''; + } + + // Sanitize the filename to remove dangerous characters and reserved names + let sanitized = sanitizeFilename(trimmed, { replacement: '_' }); + + // Additional cleanup for path traversal attempts + sanitized = sanitized.replace(/\.\./g, '').replace(/^[._]+/, ''); + + // Ensure it's not too long (filesystem limit is usually 255, leave room for timestamp/random) + if (sanitized.length > 50) { + sanitized = sanitized.substring(0, 50); + } + + // Additional safety: ensure it's not empty after sanitization + if (!sanitized || sanitized.length === 0) { + return 'project'; + } + + return sanitized; +} + +/** + * Creates a unique, sanitized temporary directory for Rust project compilation + * + * @param options - Configuration options for directory creation + * @returns Promise resolving to ProjectSetup with directory path and cleanup function + * + * @throws {Error} When directory creation fails + * + * @example + * ```typescript + * const project = await setupProject({ baseName: 'my-contract' }); + * try { + * // Use project.tempDir for compilation + * console.log('Working in:', project.tempDir); + * } finally { + * await project.cleanup(); + * } + * ``` + */ +export async function setupProject(options: ProjectSetupOptions = {}): Promise { + const { baseName = 'project', tempRoot = tmpdir() } = options; + + // Create a unique identifier to prevent collisions + const timestamp = Date.now(); + const randomId = randomBytes(8).toString('hex'); + + // Sanitize the base name + const sanitizedBase = getSanitizedDirName(baseName); + + // Create unique directory name - ensure we always have a base name + const finalBaseName = sanitizedBase || 'project'; + const dirName = `${finalBaseName}_${timestamp}_${randomId}`; + const tempDir = join(tempRoot, dirName); + + try { + // Create the temporary directory + await fs.mkdir(tempDir, { recursive: true }); + + // Verify the directory was created and is accessible + const stats = await fs.stat(tempDir); + if (!stats.isDirectory()) { + throw new Error(`Created path is not a directory: ${tempDir}`); + } + + return { + tempDir, + cleanup: () => cleanupProject(tempDir), + }; + } catch (error) { + throw new Error( + `Failed to create temporary directory: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Safely removes a temporary project directory and all its contents + * + * @param tempDir - Absolute path to the temporary directory to remove + * @throws {Error} When cleanup fails or path validation fails + * + * @example + * ```typescript + * await cleanupProject('/tmp/project_1234567890_abcdef'); + * ``` + */ +export async function cleanupProject(tempDir: string): Promise { + if (!tempDir || typeof tempDir !== 'string') { + throw new Error('Invalid tempDir provided for cleanup'); + } + + // Basic safety check: ensure we're only cleaning temp directories + const systemTempDir = tmpdir(); + if (!tempDir.startsWith(systemTempDir)) { + throw new Error(`Refusing to clean directory outside temp folder: ${tempDir}`); + } + + try { + // Check if directory exists before attempting to remove + const stats = await fs.stat(tempDir).catch(() => null); + if (!stats) { + // Directory doesn't exist, nothing to clean + return; + } + + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${tempDir}`); + } + + // Remove the directory and all its contents + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (error) { + throw new Error( + `Failed to cleanup directory ${tempDir}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +} + +/** + * Creates a basic Rust project structure with Cargo.toml and lib.rs + * + * @param tempDir - The temporary directory to create the project in + * @param rustCode - The Rust code to write to lib.rs + * @throws {Error} When file creation fails + */ +export async function createRustProject(tempDir: string, rustCode: string): Promise { + if (!tempDir || typeof tempDir !== 'string') { + throw new Error('Invalid tempDir provided'); + } + + if (!rustCode || typeof rustCode !== 'string') { + throw new Error('Invalid rustCode provided'); + } + + try { + // Create Cargo.toml for Soroban contract + const cargoToml = `[package] +name = "temp-contract" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = "21" + +[dev-dependencies] +soroban-sdk = { version = "21", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true +`; + + // Create src directory + const srcDir = join(tempDir, 'src'); + await fs.mkdir(srcDir, { recursive: true }); + + // Write Cargo.toml + await fs.writeFile(join(tempDir, 'Cargo.toml'), cargoToml, 'utf8'); + + // Write lib.rs + await fs.writeFile(join(srcDir, 'lib.rs'), rustCode, 'utf8'); + } catch (error) { + throw new Error( + `Failed to create Rust project structure: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}