Secure File Uploads in NestJS with Pompelmi
Secure File Uploads in NestJS with Pompelmi
NestJS applications often serve as the backend for complex systems — document management platforms, healthcare portals, e-commerce sites — where user-uploaded files represent a real attack surface. The @pompelmi/nestjs-integration package provides a first-class integration that fits naturally into NestJS’s module and decorator system.
TL;DR: Register PompelmiModule.forRoot() in your app module, inject PompelmiService or apply PompelmiInterceptor to your controllers, and uploaded bytes are scanned in-process before any business logic runs.
Why In-Process Scanning Matters for NestJS
Cloud-based AV scanning services are convenient, but they introduce several problems for production NestJS apps:
- Privacy risk: Files leave your infrastructure. In regulated environments (healthcare, finance, legal), this can create compliance concerns.
- Latency: A roundtrip to an external API adds 200–2000 ms per upload — unacceptable for real-time experiences.
- Vendor dependency: API rate limits, outages, or pricing changes affect your upload reliability.
- Missing context: An external scanner sees raw bytes. Your own scanner knows file metadata, user context, and can apply custom rules.
Pompelmi scans files in your Node.js process — no network call, no data leaving your server.
Installation
npm install @pompelmi/nestjs-integration @pompelmi/core# Peer dependency on @nestjs/common is already in your projectModule Registration
Register PompelmiModule in your root (or feature) module:
import { Module } from '@nestjs/common';import { PompelmiModule } from '@pompelmi/nestjs-integration';
@Module({ imports: [ PompelmiModule.forRoot({ // PompelmiModuleOptions extends ScanOptions from @pompelmi/core // Add a custom scanner if needed; omit to use the built-in heuristics }), ],})export class AppModule {}Async Configuration
When scanner options depend on ConfigService or other async providers:
import { Module } from '@nestjs/common';import { ConfigModule, ConfigService } from '@nestjs/config';import { PompelmiModule } from '@pompelmi/nestjs-integration';
@Module({ imports: [ ConfigModule.forRoot(), PompelmiModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ // Options loaded from environment/config scanner: config.get('ENABLE_YARA_SCANNER') ? myYaraScanner : undefined, }), }), ],})export class AppModule {}Using PompelmiService Directly
The PompelmiService exposes a scan() method that accepts a Buffer, Readable stream, or file path string (inherited from @pompelmi/core).
import { Controller, Post, UploadedFile, UseInterceptors, BadRequestException, ForbiddenException,} from '@nestjs/common';import { FileInterceptor } from '@nestjs/platform-express';import { PompelmiService } from '@pompelmi/nestjs-integration';import { memoryStorage } from 'multer';
@Controller('upload')export class UploadController { constructor(private readonly pompelmi: PompelmiService) {}
@Post() @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, }) ) async uploadFile(@UploadedFile() file: Express.Multer.File) { if (!file) throw new BadRequestException('No file provided');
const report = await this.pompelmi.scan(file.buffer);
if (!report.ok) { throw new ForbiddenException({ verdict: report.verdict, matches: report.matches.map((m) => m.rule), reason: 'File failed security scan', }); }
// Proceed with storage / processing return { filename: file.originalname, size: file.size, verdict: report.verdict, }; }}report is a ScanReport from @pompelmi/core:
interface ScanReport { ok: boolean; // true when verdict === 'clean' verdict: 'clean' | 'suspicious' | 'malicious'; matches: YaraMatch[]; durationMs?: number; error?: string;}Using PompelmiInterceptor
PompelmiInterceptor is a NestJS NestInterceptor that scans uploaded files and throws a ForbiddenException automatically if the verdict is not clean. Use it when you want declarative security without manual service calls in every handler.
import { Controller, Post, UploadedFile, UseInterceptors,} from '@nestjs/common';import { FileInterceptor } from '@nestjs/platform-express';import { PompelmiInterceptor } from '@pompelmi/nestjs-integration';import { memoryStorage } from 'multer';
@Controller('documents')export class DocumentsController { @Post() @UseInterceptors( FileInterceptor('document', { storage: memoryStorage() }), PompelmiInterceptor, ) async uploadDocument(@UploadedFile() file: Express.Multer.File) { // If execution reaches here, the file passed the security scan return { ok: true, filename: file.originalname }; }}Apply PompelmiInterceptor at the controller level to protect all upload routes in that controller:
@UseInterceptors(PompelmiInterceptor)@Controller('uploads')export class UploadsController { /* ... */ }Custom Scanner Integration
Pass any scanner that implements PompelmiScanner (the shared contract across adapters) to PompelmiModule:
import { PompelmiModule } from '@pompelmi/nestjs-integration';import { composeScanners, CommonHeuristicsScanner, createZipBombGuard,} from 'pompelmi';
const scanner = composeScanners( [ ['zipGuard', createZipBombGuard({ maxEntries: 1000, maxTotalUncompressedBytes: 200 * 1024 * 1024, maxCompressionRatio: 100, })], ['heuristics', CommonHeuristicsScanner], ], { parallel: false, stopOn: 'malicious', timeoutMsPerScanner: 3000, tagSourceName: true });
@Module({ imports: [ PompelmiModule.forRoot({ scanner }), ],})export class AppModule {}Handling Multiple Files
For @UploadedFiles() with FilesInterceptor, iterate and scan each file:
import { Controller, Post, UploadedFiles, UseInterceptors, ForbiddenException,} from '@nestjs/common';import { FilesInterceptor } from '@nestjs/platform-express';import { PompelmiService } from '@pompelmi/nestjs-integration';import { memoryStorage } from 'multer';
@Controller('batch-upload')export class BatchUploadController { constructor(private readonly pompelmi: PompelmiService) {}
@Post() @UseInterceptors( FilesInterceptor('files', 10, { storage: memoryStorage() }) ) async uploadFiles(@UploadedFiles() files: Express.Multer.File[]) { for (const file of files) { const report = await this.pompelmi.scan(file.buffer); if (!report.ok) { throw new ForbiddenException({ filename: file.originalname, verdict: report.verdict, }); } } return { ok: true, count: files.length }; }}Exception Mapping
Map Pompelmi verdicts to NestJS HTTP exceptions consistently:
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';import type { ScanReport } from '@pompelmi/core';
function assertClean(report: ScanReport, filename: string): void { if (report.ok) return;
const status = report.verdict === 'malicious' ? HttpStatus.FORBIDDEN : HttpStatus.UNPROCESSABLE_ENTITY; // 422 for suspicious
throw new HttpException( { ok: false, verdict: report.verdict, filename, rules: report.matches.map((m) => m.rule), }, status );}Production Checklist
-
memoryStorage()is used — disk storage lets files hit disk before scanning. -
limits.fileSizeis set in theFileInterceptoroptions. -
PompelmiModuleis registered globally or in all modules that handle uploads. - Scanner errors are logged; consider
failClosedsemantics in your error handler. - Test with EICAR test file and known-clean files in your CI pipeline.
- Pair with
ThrottlerModuleto rate-limit upload endpoints.
Summary
@pompelmi/nestjs-integration integrates cleanly with NestJS’s DI container and interceptor model. You get PompelmiService for manual control and PompelmiInterceptor for declarative protection — both building on the same in-process scanner.
Resources: