Integrating pompelmi antivirus scanning in Fastify
Fastify's plugin system and lifecycle hooks make it straightforward to add
antivirus scanning at the route level or globally. This guide covers three
integration patterns: inline scanning in a route handler, a preHandler
hook for per-route opt-in, and a full Fastify plugin you can register across
your application.
@fastify/multipart for multipart parsing.
Install ClamAV and run freshclam before proceeding.
See the Quickstart.
Install
npm install fastify @fastify/multipart pompelmi
Basic upload route
@fastify/multipart streams the file to a temporary path via
request.saveRequestFiles(). Scan the saved path with pompelmi
before deciding whether to keep or delete the file.
const Fastify = require('fastify');
const multipart = require('@fastify/multipart');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const path = require('path');
const os = require('os');
const app = Fastify({ logger: true });
app.register(multipart);
app.post('/upload', async (request, reply) => {
// saveRequestFiles writes parts to os.tmpdir() and returns metadata
const files = await request.saveRequestFiles({ tmpdir: os.tmpdir() });
if (!files.length) {
return reply.status(400).send({ error: 'No file provided.' });
}
const file = files[0];
const tmpPath = file.filepath;
try {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return reply.status(400).send({ error: 'Malware detected. Upload rejected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return reply.status(422).send({ error: 'Scan incomplete. Rejected as precaution.' });
}
const dest = path.join('/var/app/uploads', file.filename);
fs.renameSync(tmpPath, dest);
return reply.send({ status: 'ok', file: file.filename });
} catch (err) {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
return reply.status(500).send({ error: err.message });
}
});
app.listen({ port: 3000 });
Reusable preHandler hook
Fastify lifecycle hooks are the idiomatic place for cross-cutting concerns.
A preHandler hook that scans the upload before the route handler
runs keeps your route handlers clean.
// hooks/scanFileHook.js
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const os = require('os');
/**
* Fastify preHandler hook.
* Saves the multipart upload, scans it, attaches metadata to request.uploadedFile,
* and rejects the request if the verdict is not Clean.
*/
async function scanFileHook(request, reply) {
const files = await request.saveRequestFiles({ tmpdir: os.tmpdir() });
if (!files.length) {
return reply.status(400).send({ error: 'No file provided.' });
}
const file = files[0];
const tmpPath = file.filepath;
let verdict;
try {
verdict = await scan(tmpPath);
} catch (err) {
fs.unlinkSync(tmpPath);
return reply.status(500).send({ error: 'Scan failed: ' + err.message });
}
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return reply.status(400).send({ error: 'Malware detected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return reply.status(422).send({ error: 'Scan incomplete.' });
}
// Attach to request for the route handler
request.uploadedFile = file;
}
module.exports = { scanFileHook };
Register the hook on individual routes:
const { scanFileHook } = require('./hooks/scanFileHook');
app.post(
'/documents',
{ preHandler: scanFileHook },
async (request, reply) => {
const { filepath, filename } = request.uploadedFile;
const dest = path.join('/var/app/documents', filename);
fs.renameSync(filepath, dest);
return reply.send({ status: 'ok', file: filename });
}
);
Fastify plugin
For application-wide use, wrap the hook in a Fastify plugin. This lets you register it once and apply it selectively via route options.
// plugins/pompelmi.js
const fp = require('fastify-plugin');
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');
const os = require('os');
async function pompelmiPlugin(fastify, options) {
const tmpdir = options.tmpdir || os.tmpdir();
fastify.decorate('scanUpload', async function (request, reply) {
const files = await request.saveRequestFiles({ tmpdir });
if (!files.length) {
return reply.status(400).send({ error: 'No file provided.' });
}
const file = files[0];
const tmpPath = file.filepath;
let verdict;
try {
verdict = await scan(tmpPath);
} catch (err) {
fs.unlinkSync(tmpPath);
throw err;
}
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return reply.status(400).send({ error: 'Malware detected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return reply.status(422).send({ error: 'Scan incomplete.' });
}
request.uploadedFile = file;
});
}
module.exports = fp(pompelmiPlugin, { name: 'pompelmi-scan' });
Register and use:
app.register(require('./plugins/pompelmi'));
app.post(
'/upload',
{ preHandler: (req, reply) => app.scanUpload(req, reply) },
async (request, reply) => {
const { filepath, filename } = request.uploadedFile;
const dest = path.join('/var/app/uploads', filename);
fs.renameSync(filepath, dest);
return reply.send({ status: 'ok', file: filename });
}
);
Combining with JSON Schema validation
Fastify validates request bodies against JSON Schema before route handlers run. For multipart uploads the schema only applies to non-file fields. Attach it via the route options to validate form metadata alongside the scan.
app.post(
'/upload',
{
preHandler: scanFileHook,
schema: {
response: {
200: {
type: 'object',
properties: {
status: { type: 'string' },
file: { type: 'string' }
}
}
}
}
},
async (request, reply) => {
const { filepath, filename } = request.uploadedFile;
const dest = path.join('/var/app/uploads', filename);
fs.renameSync(filepath, dest);
return { status: 'ok', file: filename };
}
);
TypeScript
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
import multipart from '@fastify/multipart';
import { scan, Verdict } from 'pompelmi';
import fs from 'fs';
import path from 'path';
import os from 'os';
const app = Fastify({ logger: true });
app.register(multipart);
app.post('/upload', async (request: FastifyRequest, reply: FastifyReply) => {
const files = await request.saveRequestFiles({ tmpdir: os.tmpdir() });
if (!files.length) {
return reply.status(400).send({ error: 'No file provided.' });
}
const file = files[0];
const tmpPath = file.filepath;
try {
const verdict = await scan(tmpPath);
if (verdict === Verdict.Malicious) {
fs.unlinkSync(tmpPath);
return reply.status(400).send({ error: 'Malware detected.' });
}
if (verdict === Verdict.ScanError) {
fs.unlinkSync(tmpPath);
return reply.status(422).send({ error: 'Scan incomplete.' });
}
const dest = path.join('/var/app/uploads', file.filename);
fs.renameSync(tmpPath, dest);
return reply.send({ status: 'ok', file: file.filename });
} catch (err: unknown) {
if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
const message = err instanceof Error ? err.message : String(err);
return reply.status(500).send({ error: message });
}
});
app.listen({ port: 3000 });