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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Express } from 'express';

export interface ContentScanner {
scan(file: Express.Multer.File): Promise<void>;
}
133 changes: 133 additions & 0 deletions meridian-api/src/upload/providers/clam-av.scanner.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigService>;

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<ConfigService>;

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);
});
});
});
116 changes: 116 additions & 0 deletions meridian-api/src/upload/providers/clam-av.scanner.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const host = this.configService.get<string>('CLAMAV_HOST');
const port = this.configService.get<number>('CLAMAV_PORT') || 3310;
const preferTcp = this.configService.get<boolean>('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<void> {
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<void> {
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}`));
}
});
});
}
}
122 changes: 122 additions & 0 deletions meridian-api/src/upload/providers/image-dimensions.scanner.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ConfigService>;

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<ConfigService>;

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);
});
});
Loading
Loading