How to use pompelmi in a NestJS application
NestJS is a TypeScript-first framework built on top of Express or Fastify. The idiomatic way to add antivirus scanning is to write a NestJS interceptor that inspects uploaded files before the controller method runs. This guide builds the provider, interceptor, and module from scratch without relying on any third-party NestJS wrapper.
FileInterceptor from
@nestjs/platform-express uses Multer under the hood, so the file
will be on disk at file.path by the time your interceptor runs.
Install
npm install pompelmi @nestjs/platform-express @nestjs/common @nestjs/core npm install --save-dev @types/multer
Scan provider
Encapsulate the pompelmi call in an injectable service. This makes it easy to mock in unit tests and swap out the underlying scanner without touching controller code.
// scan/scan.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { scan, Verdict } from 'pompelmi';
export type ScanVerdict = typeof Verdict.Clean
| typeof Verdict.Malicious
| typeof Verdict.ScanError;
@Injectable()
export class ScanService {
/**
* Scan an absolute file path with ClamAV via pompelmi.
* Returns the Verdict Symbol, or throws InternalServerErrorException
* if clamscan is unavailable.
*/
async scanFile(filePath: string): Promise<ScanVerdict> {
try {
return await scan(filePath) as ScanVerdict;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
throw new InternalServerErrorException('ClamAV scan failed: ' + message);
}
}
}
Antivirus interceptor
The interceptor runs after FileInterceptor has written the
upload to disk. It calls ScanService, deletes the file on
rejection, and throws the appropriate HTTP exception.
// scan/scan.interceptor.ts
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
BadRequestException,
UnprocessableEntityException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Verdict } from 'pompelmi';
import * as fs from 'fs';
import { ScanService } from './scan.service';
@Injectable()
export class AntivirusInterceptor implements NestInterceptor {
constructor(private readonly scanService: ScanService) {}
async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const request = ctx.switchToHttp().getRequest();
const file: Express.Multer.File | undefined = request.file;
if (!file) {
// No file in this request — skip scanning
return next.handle();
}
const verdict = await this.scanService.scanFile(file.path);
if (verdict === Verdict.Malicious) {
this.deleteFile(file.path);
throw new BadRequestException('Malware detected. Upload rejected.');
}
if (verdict === Verdict.ScanError) {
this.deleteFile(file.path);
throw new UnprocessableEntityException(
'Scan could not complete. Upload rejected as a precaution.'
);
}
// Verdict.Clean — continue to controller
return next.handle();
}
private deleteFile(filePath: string): void {
try {
fs.unlinkSync(filePath);
} catch (_) {
// Best-effort deletion
}
}
}
Scan module
Bundle the service and interceptor into a module so they can be imported into any feature module that needs antivirus scanning.
// scan/scan.module.ts
import { Module } from '@nestjs/common';
import { ScanService } from './scan.service';
import { AntivirusInterceptor } from './scan.interceptor';
@Module({
providers: [ScanService, AntivirusInterceptor],
exports: [ScanService, AntivirusInterceptor],
})
export class ScanModule {}
Controller
Import ScanModule in the feature module, then apply
AntivirusInterceptor after FileInterceptor on the
upload route.
// upload/upload.controller.ts
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
HttpCode,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as path from 'path';
import { AntivirusInterceptor } from '../scan/scan.interceptor';
@Controller('upload')
export class UploadController {
@Post()
@HttpCode(200)
@UseInterceptors(
// 1. FileInterceptor writes the file to disk
FileInterceptor('file', {
storage: diskStorage({
destination: '/tmp/uploads',
filename: (_req, file, cb) => {
const unique = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, unique + path.extname(file.originalname));
},
}),
}),
// 2. AntivirusInterceptor scans the saved file
AntivirusInterceptor,
)
handleUpload(@UploadedFile() file: Express.Multer.File) {
// At this point the file is guaranteed Clean
return { status: 'ok', file: file.filename };
}
}
Register the modules in your application root:
// app.module.ts
import { Module } from '@nestjs/common';
import { ScanModule } from './scan/scan.module';
import { UploadModule } from './upload/upload.module';
@Module({
imports: [ScanModule, UploadModule],
})
export class AppModule {}
// upload/upload.module.ts
import { Module } from '@nestjs/common';
import { ScanModule } from '../scan/scan.module';
import { UploadController } from './upload.controller';
@Module({
imports: [ScanModule],
controllers: [UploadController],
})
export class UploadModule {}
Unit testing the interceptor
Mock ScanService to test the interceptor in isolation from ClamAV.
// scan/scan.interceptor.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';
import { Verdict } from 'pompelmi';
import { AntivirusInterceptor } from './scan.interceptor';
import { ScanService } from './scan.service';
describe('AntivirusInterceptor', () => {
let interceptor: AntivirusInterceptor;
let scanService: jest.Mocked<ScanService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AntivirusInterceptor,
{
provide: ScanService,
useValue: { scanFile: jest.fn() },
},
],
}).compile();
interceptor = module.get(AntivirusInterceptor);
scanService = module.get(ScanService);
});
function mockContext(filePath: string | undefined) {
return {
switchToHttp: () => ({
getRequest: () => ({ file: filePath ? { path: filePath } : undefined }),
}),
} as unknown as ExecutionContext;
}
const next: CallHandler = { handle: () => of('ok') };
it('calls next when file is clean', async () => {
scanService.scanFile.mockResolvedValue(Verdict.Clean);
const result = await interceptor.intercept(mockContext('/tmp/file.pdf'), next);
expect(result).toBeDefined();
});
it('throws BadRequestException for malicious file', async () => {
scanService.scanFile.mockResolvedValue(Verdict.Malicious);
await expect(interceptor.intercept(mockContext('/tmp/evil.exe'), next))
.rejects.toBeInstanceOf(BadRequestException);
});
it('skips scan when no file is present', async () => {
const result = await interceptor.intercept(mockContext(undefined), next);
expect(scanService.scanFile).not.toHaveBeenCalled();
expect(result).toBeDefined();
});
});