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.

This guide uses @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 });