diff --git a/meridian-api/src/upload/interfaces/content-scanner.interface.ts b/meridian-api/src/upload/interfaces/content-scanner.interface.ts new file mode 100644 index 00000000..a01e36d1 --- /dev/null +++ b/meridian-api/src/upload/interfaces/content-scanner.interface.ts @@ -0,0 +1,5 @@ +import { Express } from 'express'; + +export interface ContentScanner { + scan(file: Express.Multer.File): Promise; +} diff --git a/meridian-api/src/upload/providers/clam-av.scanner.spec.ts b/meridian-api/src/upload/providers/clam-av.scanner.spec.ts new file mode 100644 index 00000000..96756329 --- /dev/null +++ b/meridian-api/src/upload/providers/clam-av.scanner.spec.ts @@ -0,0 +1,133 @@ +import { ClamAvScanner } from './clam-av.scanner'; +import { ConfigService } from '@nestjs/config'; +import { BadRequestException } from '@nestjs/common'; +import * as net from 'net'; +import * as child_process from 'child_process'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; + +describe('ClamAvScanner', () => { + let scanner: ClamAvScanner; + let configService: jest.Mocked; + + beforeEach(() => { + configService = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'CLAMAV_HOST') return 'localhost'; + if (key === 'CLAMAV_PORT') return 3310; + if (key === 'CLAMAV_PREFER_TCP') return true; + return undefined; + }), + } as unknown as jest.Mocked; + + scanner = new ClamAvScanner(configService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function makeFile(buffer: Buffer): Express.Multer.File { + return { + buffer, + originalname: 'virus-test.txt', + } as Express.Multer.File; + } + + describe('scanTcp', () => { + it('successfully scans clean file', async () => { + const mockSocket = new EventEmitter() as any; + mockSocket.write = jest.fn(); + mockSocket.end = jest.fn(); + + jest.spyOn(net, 'createConnection').mockReturnValue(mockSocket); + + const scanPromise = scanner.scan(makeFile(Buffer.from('clean'))); + + // Simulate connection established + mockSocket.emit('connect'); + + // Simulate clamd response: stream: OK + setTimeout(() => { + mockSocket.emit('data', Buffer.from('stream: OK\n')); + mockSocket.emit('end'); + }, 10); + + await expect(scanPromise).resolves.toBeUndefined(); + }); + + it('rejects infected file with BadRequestException', async () => { + const mockSocket = new EventEmitter() as any; + mockSocket.write = jest.fn(); + mockSocket.end = jest.fn(); + + jest.spyOn(net, 'createConnection').mockReturnValue(mockSocket); + + const scanPromise = scanner.scan(makeFile(Buffer.from('virus'))); + + mockSocket.emit('connect'); + + // Simulate clamd response: stream: Eicar-Test-Signature FOUND + setTimeout(() => { + mockSocket.emit('data', Buffer.from('stream: Eicar-Test-Signature FOUND\n')); + mockSocket.emit('end'); + }, 10); + + await expect(scanPromise).rejects.toThrow(BadRequestException); + await expect(scanPromise).rejects.toThrow(/Virus detected in uploaded file/i); + }); + }); + + describe('scanSpawn (fallback)', () => { + beforeEach(() => { + // Disable TCP so it falls back to spawn + configService.get.mockImplementation((key: string) => { + if (key === 'CLAMAV_PREFER_TCP') return false; + return undefined; + }); + }); + + it('successfully scans clean file using clamscan spawn', async () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + jest.spyOn(child_process, 'spawn').mockReturnValue(mockChild); + + const scanPromise = scanner.scan(makeFile(Buffer.from('clean'))); + + // Simulate exit code 0 + setTimeout(() => { + mockChild.emit('close', 0); + }, 10); + + await expect(scanPromise).resolves.toBeUndefined(); + }); + + it('rejects infected file using clamscan spawn', async () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(true); + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); + + const mockChild = new EventEmitter() as any; + mockChild.stdout = new EventEmitter(); + mockChild.stderr = new EventEmitter(); + + jest.spyOn(child_process, 'spawn').mockReturnValue(mockChild); + + const scanPromise = scanner.scan(makeFile(Buffer.from('virus'))); + + // Simulate exit code 1 (Virus found) + setTimeout(() => { + mockChild.emit('close', 1); + }, 10); + + await expect(scanPromise).rejects.toThrow(BadRequestException); + await expect(scanPromise).rejects.toThrow(/Virus detected in uploaded file/i); + }); + }); +}); diff --git a/meridian-api/src/upload/providers/clam-av.scanner.ts b/meridian-api/src/upload/providers/clam-av.scanner.ts new file mode 100644 index 00000000..4bc2c0a3 --- /dev/null +++ b/meridian-api/src/upload/providers/clam-av.scanner.ts @@ -0,0 +1,116 @@ +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ContentScanner } from '../interfaces/content-scanner.interface'; +import * as net from 'net'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class ClamAvScanner implements ContentScanner { + private readonly logger = new Logger(ClamAvScanner.name); + + constructor(private readonly configService: ConfigService) {} + + async scan(file: Express.Multer.File): Promise { + const host = this.configService.get('CLAMAV_HOST'); + const port = this.configService.get('CLAMAV_PORT') || 3310; + const preferTcp = this.configService.get('CLAMAV_PREFER_TCP', true); + + if (preferTcp && host) { + try { + await this.scanTcp(file.buffer, host, port); + return; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; + } + this.logger.warn(`ClamAV TCP scan failed, falling back to clamscan spawn: ${err.message}`); + } + } + + // Fallback: spawn clamscan + await this.scanSpawn(file.buffer); + } + + private scanTcp(buffer: Buffer, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + let response = ''; + + socket.on('connect', () => { + // Send INSTREAM command + socket.write('zINSTREAM\0'); + + // Send buffer in chunks + const chunkSize = 2048; + for (let i = 0; i < buffer.length; i += chunkSize) { + const chunk = buffer.subarray(i, i + chunkSize); + const sizeBuf = Buffer.alloc(4); + sizeBuf.writeUInt32BE(chunk.length, 0); + socket.write(sizeBuf); + socket.write(chunk); + } + + // Terminate stream with zero-size chunk + const zeroSize = Buffer.alloc(4); + zeroSize.writeUInt32BE(0, 0); + socket.write(zeroSize); + }); + + socket.on('data', (chunk) => { + response += chunk.toString(); + }); + + socket.on('end', () => { + if (response.includes('FOUND')) { + reject(new BadRequestException('Virus detected in uploaded file')); + } else if (response.includes('OK') || response.includes('stream: OK')) { + resolve(); + } else { + reject(new Error(`Unexpected ClamAV response: ${response}`)); + } + }); + + socket.on('error', (err) => { + reject(err); + }); + }); + } + + private scanSpawn(buffer: Buffer): Promise { + return new Promise((resolve, reject) => { + // Create a temporary file in the workspace + const tempDir = path.join(process.cwd(), 'temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + const tempFilePath = path.join(tempDir, `scan-${Date.now()}-${Math.random().toString(36).substring(7)}`); + + try { + fs.writeFileSync(tempFilePath, buffer); + } catch (err) { + return reject(new Error(`Failed to write temp file for clamscan: ${err.message}`)); + } + + const child = spawn('clamscan', [tempFilePath]); + + child.on('error', (err) => { + try { fs.unlinkSync(tempFilePath); } catch {} + reject(new Error(`Failed to spawn clamscan: ${err.message}`)); + }); + + child.on('close', (code) => { + try { fs.unlinkSync(tempFilePath); } catch {} + + if (code === 0) { + resolve(); + } else if (code === 1) { + reject(new BadRequestException('Virus detected in uploaded file')); + } else { + reject(new Error(`clamscan exited with code ${code}`)); + } + }); + }); + } +} diff --git a/meridian-api/src/upload/providers/image-dimensions.scanner.spec.ts b/meridian-api/src/upload/providers/image-dimensions.scanner.spec.ts new file mode 100644 index 00000000..a91dc6ee --- /dev/null +++ b/meridian-api/src/upload/providers/image-dimensions.scanner.spec.ts @@ -0,0 +1,122 @@ +import { ImageDimensionsScanner } from './image-dimensions.scanner'; +import { ConfigService } from '@nestjs/config'; +import { BadRequestException } from '@nestjs/common'; + +describe('ImageDimensionsScanner', () => { + let scanner: ImageDimensionsScanner; + let configService: jest.Mocked; + + beforeEach(() => { + configService = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'MAX_IMAGE_WIDTH') return 4096; + if (key === 'MAX_IMAGE_HEIGHT') return 4096; + return undefined; + }), + } as unknown as jest.Mocked; + + scanner = new ImageDimensionsScanner(configService); + }); + + function makeFile(mimetype: string, buffer: Buffer): Express.Multer.File { + return { + mimetype, + buffer, + originalname: 'test.img', + } as Express.Multer.File; + } + + // 100x100 PNG + const validPngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // Signature + 0x00, 0x00, 0x00, 0x0d, // Length of IHDR + 0x49, 0x48, 0x44, 0x52, // 'IHDR' + 0x00, 0x00, 0x00, 0x64, // Width: 100 + 0x00, 0x00, 0x00, 0x64, // Height: 100 + ]); + + // 5000x100 PNG (oversized width) + const oversizedPngBuffer = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x13, 0x88, // Width: 5000 + 0x00, 0x00, 0x00, 0x64, // Height: 100 + ]); + + // 100x100 GIF + const validGifBuffer = Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x64, 0x00, // Logical width: 100 + 0x64, 0x00, // Logical height: 100 + ]); + + // 100x5000 GIF (oversized height) + const oversizedGifBuffer = Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x64, 0x00, // Logical width: 100 + 0x88, 0x13, // Logical height: 5000 + ]); + + // 100x100 JPEG SOF0 + const validJpegBuffer = Buffer.from([ + 0xff, 0xd8, // SOI + 0xff, 0xc0, // SOF0 + 0x00, 0x0b, // Segment length: 11 + 0x08, // Precision + 0x00, 0x64, // Height: 100 + 0x00, 0x64, // Width: 100 + ]); + + // 6000x100 JPEG SOF0 + const oversizedJpegBuffer = Buffer.from([ + 0xff, 0xd8, + 0xff, 0xc0, + 0x00, 0x0b, + 0x08, + 0x00, 0x64, // Height: 100 + 0x17, 0x70, // Width: 6000 + ]); + + it('accepts valid PNG image', async () => { + const file = makeFile('image/png', validPngBuffer); + await expect(scanner.scan(file)).resolves.toBeUndefined(); + }); + + it('rejects oversized PNG image', async () => { + const file = makeFile('image/png', oversizedPngBuffer); + await expect(scanner.scan(file)).rejects.toThrow(BadRequestException); + await expect(scanner.scan(file)).rejects.toThrow(/exceed the maximum/i); + }); + + it('accepts valid GIF image', async () => { + const file = makeFile('image/gif', validGifBuffer); + await expect(scanner.scan(file)).resolves.toBeUndefined(); + }); + + it('rejects oversized GIF image', async () => { + const file = makeFile('image/gif', oversizedGifBuffer); + await expect(scanner.scan(file)).rejects.toThrow(BadRequestException); + }); + + it('accepts valid JPEG image', async () => { + const file = makeFile('image/jpeg', validJpegBuffer); + await expect(scanner.scan(file)).resolves.toBeUndefined(); + }); + + it('rejects oversized JPEG image', async () => { + const file = makeFile('image/jpeg', oversizedJpegBuffer); + await expect(scanner.scan(file)).rejects.toThrow(BadRequestException); + }); + + it('skips scanning for non-image MIME types', async () => { + const file = makeFile('application/pdf', Buffer.from('%PDF-1.4')); + await expect(scanner.scan(file)).resolves.toBeUndefined(); + }); + + it('throws BadRequestException for corrupted/invalid images', async () => { + const file = makeFile('image/png', Buffer.from([0x00, 0x01, 0x02])); + await expect(scanner.scan(file)).rejects.toThrow(BadRequestException); + await expect(scanner.scan(file)).rejects.toThrow(/invalid image/i); + }); +}); diff --git a/meridian-api/src/upload/providers/image-dimensions.scanner.ts b/meridian-api/src/upload/providers/image-dimensions.scanner.ts new file mode 100644 index 00000000..3a17f5a4 --- /dev/null +++ b/meridian-api/src/upload/providers/image-dimensions.scanner.ts @@ -0,0 +1,113 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ContentScanner } from '../interfaces/content-scanner.interface'; + +@Injectable() +export class ImageDimensionsScanner implements ContentScanner { + constructor(private readonly configService: ConfigService) {} + + async scan(file: Express.Multer.File): Promise { + if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.mimetype)) { + return; + } + + const maxWidth = this.configService.get('MAX_IMAGE_WIDTH') || 4096; + const maxHeight = this.configService.get('MAX_IMAGE_HEIGHT') || 4096; + + let dimensions: { width: number; height: number }; + try { + dimensions = this.getImageDimensions(file.buffer, file.mimetype); + } catch (err) { + throw new BadRequestException(`Invalid image or unable to parse dimensions: ${err.message}`); + } + + if (dimensions.width > maxWidth || dimensions.height > maxHeight) { + throw new BadRequestException( + `Image dimensions (${dimensions.width}x${dimensions.height}) exceed the maximum allowed limit of ${maxWidth}x${maxHeight}`, + ); + } + } + + private getImageDimensions(buffer: Buffer, mimetype: string): { width: number; height: number } { + if (mimetype === 'image/png') { + return this.parsePng(buffer); + } else if (mimetype === 'image/gif') { + return this.parseGif(buffer); + } else if (mimetype === 'image/jpeg') { + return this.parseJpeg(buffer); + } + throw new Error('Unsupported image mimetype'); + } + + private parsePng(buffer: Buffer): { width: number; height: number } { + // PNG signature is 8 bytes. IHDR starts at offset 8. + // Length: 4 bytes (offset 8-11) + // Marker: 'IHDR' (offset 12-15) + // Width: 4 bytes (offset 16-19) + // Height: 4 bytes (offset 20-23) + if (buffer.length < 24) { + throw new Error('Buffer too small for PNG header'); + } + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return { width, height }; + } + + private parseGif(buffer: Buffer): { width: number; height: number } { + // GIF header: signature 'GIF87a' or 'GIF89a' (6 bytes) + // Logical screen width: 2 bytes (offset 6) + // Logical screen height: 2 bytes (offset 8) + if (buffer.length < 10) { + throw new Error('Buffer too small for GIF header'); + } + const width = buffer.readUInt16LE(6); + const height = buffer.readUInt16LE(8); + return { width, height }; + } + + private parseJpeg(buffer: Buffer): { width: number; height: number } { + let offset = 2; // skip SOI (0xFFD8) + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) { + throw new Error('Invalid JPEG marker structure'); + } + + // Skip padding 0xFF bytes + while (buffer[offset] === 0xff && offset < buffer.length) { + offset++; + } + + if (offset >= buffer.length) { + throw new Error('Invalid JPEG structure: unexpected EOF'); + } + + const marker = buffer[offset]; + offset++; + + // Markers with no payload length + if (marker === 0xd9 || marker === 0xd8) { + continue; + } + + // SOF0 through SOF15 (except SOF4, SOF8, SOF12 which are not standard frame markers) + const isSof = (marker >= 0xc0 && marker <= 0xcf) && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; + + if (isSof) { + if (offset + 7 > buffer.length) { + throw new Error('Invalid JPEG structure: SOF segment truncated'); + } + const height = buffer.readUInt16BE(offset + 3); + const width = buffer.readUInt16BE(offset + 5); + return { width, height }; + } + + // Skip segment + if (offset + 2 > buffer.length) { + throw new Error('Invalid JPEG segment length'); + } + const segmentLength = buffer.readUInt16BE(offset); + offset += segmentLength; + } + throw new Error('SOF marker not found in JPEG'); + } +} diff --git a/meridian-api/src/upload/upload.entity.ts b/meridian-api/src/upload/upload.entity.ts new file mode 100644 index 00000000..0a0105ee --- /dev/null +++ b/meridian-api/src/upload/upload.entity.ts @@ -0,0 +1,45 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Upload { + @PrimaryGeneratedColumn() + id: number; + + @Column({ + type: 'varchar', + length: 64, + unique: true, + nullable: false, + }) + contentHash: string; // SHA-256 hash of the file buffer + + @Column({ + type: 'varchar', + length: 1024, + nullable: false, + }) + url: string; + + @Column({ + type: 'varchar', + length: 256, + nullable: false, + }) + originalName: string; + + @Column({ + type: 'varchar', + length: 128, + nullable: false, + }) + mimeType: string; + + @Column({ + type: 'integer', + nullable: false, + }) + size: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/meridian-api/src/upload/upload.module.ts b/meridian-api/src/upload/upload.module.ts index 0b58c6ce..06431092 100644 --- a/meridian-api/src/upload/upload.module.ts +++ b/meridian-api/src/upload/upload.module.ts @@ -1,18 +1,27 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; import { LocalStorageProvider } from './providers/local-storage.provider'; import { S3StorageProvider } from './providers/s3-storage.provider'; +import { Upload } from './upload.entity'; +import { ImageDimensionsScanner } from './providers/image-dimensions.scanner'; +import { ClamAvScanner } from './providers/clam-av.scanner'; import uploadConfig from './config/upload.config'; @Module({ - imports: [ConfigModule.forFeature(uploadConfig)], + imports: [ + ConfigModule.forFeature(uploadConfig), + TypeOrmModule.forFeature([Upload]), + ], controllers: [UploadController], providers: [ UploadService, LocalStorageProvider, S3StorageProvider, + ImageDimensionsScanner, + ClamAvScanner, { provide: 'STORAGE_PROVIDER', inject: [ConfigService, LocalStorageProvider, S3StorageProvider], @@ -26,6 +35,14 @@ import uploadConfig from './config/upload.config'; return providerType.toLowerCase() === 's3' ? s3 : local; }, }, + { + provide: 'CONTENT_SCANNERS', + inject: [ImageDimensionsScanner, ClamAvScanner], + useFactory: ( + imageScanner: ImageDimensionsScanner, + clamScanner: ClamAvScanner, + ) => [imageScanner, clamScanner], + }, ], exports: [UploadService], }) diff --git a/meridian-api/src/upload/upload.service.spec.ts b/meridian-api/src/upload/upload.service.spec.ts index 8fc92c00..5e655cff 100644 --- a/meridian-api/src/upload/upload.service.spec.ts +++ b/meridian-api/src/upload/upload.service.spec.ts @@ -1,11 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { UploadService, ALLOWED_MIME_TYPES, MAX_FILE_SIZE, } from './upload.service'; import { StorageProvider } from './storage-provider.interface'; +import { Upload } from './upload.entity'; +import { ContentScanner } from './interfaces/content-scanner.interface'; // Valid magic-byte headers for each allowed type const MAGIC: Record = { @@ -15,6 +18,13 @@ const MAGIC: Record = { 'application/pdf': Buffer.from([0x25, 0x50, 0x44, 0x46, 0x2d]), }; +const EXTENSIONS: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'application/pdf': '.pdf', +}; + function makeFile( overrides: Partial & { mimetype: string }, ): Express.Multer.File { @@ -30,16 +40,41 @@ function makeFile( describe('UploadService', () => { let service: UploadService; let storageProvider: jest.Mocked; + let mockUploadRepository: any; + let mockImageDimensionsScanner: jest.Mocked; + let mockClamAvScanner: jest.Mocked; beforeEach(async () => { storageProvider = { uploadFile: jest.fn().mockResolvedValue('/uploads/test.png'), }; + mockUploadRepository = { + findOne: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((entity) => Promise.resolve(entity)), + }; + + mockImageDimensionsScanner = { + scan: jest.fn().mockResolvedValue(undefined), + }; + + mockClamAvScanner = { + scan: jest.fn().mockResolvedValue(undefined), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ UploadService, { provide: 'STORAGE_PROVIDER', useValue: storageProvider }, + { + provide: 'CONTENT_SCANNERS', + useValue: [mockImageDimensionsScanner, mockClamAvScanner], + }, + { + provide: getRepositoryToken(Upload), + useValue: mockUploadRepository, + }, ], }).compile(); @@ -55,12 +90,14 @@ describe('UploadService', () => { it.each(ALLOWED_MIME_TYPES)( 'accepts valid %s files and delegates to the storage provider', async (mime) => { - const file = makeFile({ mimetype: mime, originalname: 'file' }); - storageProvider.uploadFile.mockResolvedValueOnce('/uploads/file'); + const ext = EXTENSIONS[mime]; + const filename = `file${ext}`; + const file = makeFile({ mimetype: mime, originalname: filename }); + storageProvider.uploadFile.mockResolvedValueOnce(`/uploads/${filename}`); const result = await service.uploadFile(file); expect(storageProvider.uploadFile).toHaveBeenCalledWith(file); - expect(result).toEqual({ url: '/uploads/file', originalName: 'file' }); + expect(result).toEqual({ url: `/uploads/${filename}`, originalName: filename }); }, ); @@ -144,4 +181,69 @@ describe('UploadService', () => { ]), ); }); + + // ── new pipeline features ─────────────────────────────────────────────────── + + it('deduplicates uploads: returns existing URL without re-uploading if file hash exists', async () => { + const file = makeFile({ mimetype: 'image/png', originalname: 'duplicate.png' }); + const existingRecord = { + url: '/uploads/existing-duplicate.png', + originalName: 'sanitized-duplicate.png', + contentHash: 'hash123', + }; + + mockUploadRepository.findOne.mockResolvedValueOnce(existingRecord); + + const result = await service.uploadFile(file); + + expect(mockUploadRepository.findOne).toHaveBeenCalled(); + expect(storageProvider.uploadFile).not.toHaveBeenCalled(); + expect(result).toEqual({ + url: existingRecord.url, + originalName: existingRecord.originalName, + }); + }); + + it('sanitizes and HTML-escapes originalName, and enforces extension matching MIME type', async () => { + const file = makeFile({ + mimetype: 'image/png', + originalname: '../../path/to/.jpg', // mismatch ext + XSS payload + path traversal + }); + + storageProvider.uploadFile.mockResolvedValueOnce('/uploads/sanitized.png'); + + const result = await service.uploadFile(file); + + // .jpg is mismatched for image/png MIME, so it gets corrected to .png + // The path separators/traversal is stripped: only basename is kept. + // HTML tags in the basename are escaped. + const expectedName = '<img src=x onerror=alert(1)>.png'; + expect(result.originalName).toBe(expectedName); + expect(storageProvider.uploadFile).toHaveBeenCalled(); + expect(mockUploadRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + originalName: expectedName, + }), + ); + }); + + it('rejects upload when ImageDimensionsScanner throws error (decompression bomb)', async () => { + const file = makeFile({ mimetype: 'image/png', originalname: 'bomb.png' }); + mockImageDimensionsScanner.scan.mockRejectedValueOnce( + new BadRequestException('Image dimensions exceed the maximum allowed limit'), + ); + + await expect(service.uploadFile(file)).rejects.toThrow('Image dimensions exceed the maximum allowed limit'); + expect(storageProvider.uploadFile).not.toHaveBeenCalled(); + }); + + it('rejects upload when ClamAvScanner throws error (virus found)', async () => { + const file = makeFile({ mimetype: 'image/png', originalname: 'virus.png' }); + mockClamAvScanner.scan.mockRejectedValueOnce( + new BadRequestException('Virus detected in uploaded file'), + ); + + await expect(service.uploadFile(file)).rejects.toThrow('Virus detected in uploaded file'); + expect(storageProvider.uploadFile).not.toHaveBeenCalled(); + }); }); diff --git a/meridian-api/src/upload/upload.service.ts b/meridian-api/src/upload/upload.service.ts index 00b2589c..1a3ffb34 100644 --- a/meridian-api/src/upload/upload.service.ts +++ b/meridian-api/src/upload/upload.service.ts @@ -1,5 +1,11 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { StorageProvider } from './storage-provider.interface'; +import { Upload } from './upload.entity'; +import { ContentScanner } from './interfaces/content-scanner.interface'; +import * as crypto from 'crypto'; +import * as path from 'path'; /** * Magic-byte signatures for allowed file types. @@ -24,11 +30,22 @@ export const ALLOWED_MIME_TYPES = Object.keys(MAGIC_BYTES); /** Maximum file size in bytes (5 MB). Also enforced at the controller layer. */ export const MAX_FILE_SIZE = 5 * 1024 * 1024; +const MIME_TO_EXTENSIONS: Record = { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/gif': ['.gif'], + 'application/pdf': ['.pdf'], +}; + @Injectable() export class UploadService { constructor( @Inject('STORAGE_PROVIDER') private readonly storageProvider: StorageProvider, + @Inject('CONTENT_SCANNERS') + private readonly scanners: ContentScanner[], + @InjectRepository(Upload) + private readonly uploadRepository: Repository, ) {} async uploadFile( @@ -38,11 +55,51 @@ export class UploadService { throw new BadRequestException('No file uploaded or file is invalid'); } + // Compute SHA-256 hash of the buffer + const hash = crypto.createHash('sha256').update(file.buffer).digest('hex'); + + // Check if the hash exists for deduplication + const existingUpload = await this.uploadRepository.findOne({ + where: { contentHash: hash }, + }); + + if (existingUpload) { + return { + url: existingUpload.url, + originalName: existingUpload.originalName, + }; + } + this.validateMimeType(file); this.validateMagicBytes(file); + // Execute scanners (image dimension check & antivirus scan) + for (const scanner of this.scanners) { + await scanner.scan(file); + } + + // Sanitize/normalize originalName + const sanitizedName = this.sanitizeOriginalName( + file.originalname, + file.mimetype, + ); + + // Update file originalname before storing, so the stored file has the safe name + file.originalname = sanitizedName; + const url = await this.storageProvider.uploadFile(file); - return { url, originalName: file.originalname }; + + // Save metadata in database + const upload = this.uploadRepository.create({ + contentHash: hash, + url, + originalName: sanitizedName, + mimeType: file.mimetype, + size: file.size, + }); + await this.uploadRepository.save(upload); + + return { url, originalName: sanitizedName }; } // --------------------------------------------------------------------------- @@ -77,4 +134,40 @@ export class UploadService { ); } } + + private sanitizeOriginalName(originalname: string, mimetype: string): string { + if (!originalname) { + originalname = 'unnamed'; + } + + // 1. Strip path separators + const base = path.basename(originalname); + + // 2. Extract extension and stem + const ext = path.extname(base).toLowerCase(); + const stem = path.basename(base, ext); + + // 3. Enforce extension matches MIME + const allowedExtensions = MIME_TO_EXTENSIONS[mimetype] || []; + let targetExt = ext; + if (!allowedExtensions.includes(ext)) { + targetExt = allowedExtensions[0] || ''; + } + + // 4. HTML-escape stem and extension + const escapedStem = this.htmlEscape(stem); + const escapedExt = this.htmlEscape(targetExt); + + return `${escapedStem}${escapedExt}`; + } + + private htmlEscape(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); + } }