File upload antivirus scanning with pompelmi and Hapi.js

Hapi.js has first-class support for multipart file uploads through its payload.output option. Unlike Multer-based frameworks, Hapi can write uploads directly to disk and expose the file path, making it a natural fit for pompelmi's file-path-based scanning API.

This guide uses Hapi v21 with Node.js 18+. ClamAV must be installed and definitions must be up to date. See the Quickstart.

Install

npm install @hapi/hapi pompelmi

Multipart payload configuration

Set payload.output: 'file' on the route to instruct Hapi to write the upload to a temporary file and expose its path in request.payload. Without this, Hapi buffers the upload in memory.

// Route-level payload config
{
  payload: {
    output: 'file',     // Write to a temp file, not a Buffer
    parse: true,        // Auto-parse multipart
    multipart: true,
    maxBytes: 50 * 1024 * 1024,   // 50 MB limit
    uploads: '/tmp/hapi-uploads'   // Temp directory
  }
}

Basic upload route

'use strict';

const Hapi   = require('@hapi/hapi');
const { scan, Verdict } = require('pompelmi');
const fs   = require('fs');
const path = require('path');

async function init() {
  const server = Hapi.server({ port: 3000 });

  server.route({
    method:  'POST',
    path:    '/upload',
    options: {
      payload: {
        output:    'file',
        parse:     true,
        multipart: true,
        maxBytes:  50 * 1024 * 1024,
        uploads:   '/tmp/hapi-uploads'
      }
    },
    handler: async (request, h) => {
      const upload = request.payload.file;

      if (!upload) {
        return h.response({ error: 'No file provided.' }).code(400);
      }

      const tmpPath = upload.path; // Absolute path Hapi wrote the file to

      try {
        const verdict = await scan(tmpPath);

        if (verdict === Verdict.Malicious) {
          fs.unlinkSync(tmpPath);
          return h.response({ error: 'Malware detected. Upload rejected.' }).code(400);
        }

        if (verdict === Verdict.ScanError) {
          fs.unlinkSync(tmpPath);
          return h.response({ error: 'Scan incomplete. Rejected as precaution.' }).code(422);
        }

        const dest = path.join('/var/app/uploads', upload.filename);
        fs.renameSync(tmpPath, dest);
        return { status: 'ok', file: upload.filename };

      } catch (err) {
        if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
        return h.response({ error: err.message }).code(500);
      }
    }
  });

  await server.start();
  console.log('Server running on', server.info.uri);
}

init();

Request lifecycle extension

Hapi's server.ext() allows you to hook into the request lifecycle. The onPreHandler extension point runs after the payload is parsed and before the route handler — exactly the right place for antivirus scanning.

server.ext('onPreHandler', async (request, h) => {
  // Only intercept routes that have a file payload
  const upload = request.payload && request.payload.file;
  if (!upload || !upload.path) return h.continue;

  const tmpPath = upload.path;

  let verdict;
  try {
    verdict = await scan(tmpPath);
  } catch (err) {
    fs.unlinkSync(tmpPath);
    return h.response({ error: 'Scan failed: ' + err.message }).code(500).takeover();
  }

  if (verdict === Verdict.Malicious) {
    fs.unlinkSync(tmpPath);
    return h.response({ error: 'Malware detected.' }).code(400).takeover();
  }

  if (verdict === Verdict.ScanError) {
    fs.unlinkSync(tmpPath);
    return h.response({ error: 'Scan incomplete.' }).code(422).takeover();
  }

  return h.continue;
});
.takeover() is required when you want the response from the extension to be sent directly, bypassing the route handler.

Reusable Hapi plugin

Package the extension into a Hapi plugin for easy reuse across applications.

// plugins/pompelmiScan.js
'use strict';

const { scan, Verdict } = require('pompelmi');
const fs = require('fs');

const pompelmiScanPlugin = {
  name: 'pompelmi-scan',
  version: '1.0.0',
  async register(server, options) {
    const rejectScanError = options.rejectScanError !== false; // default: true

    server.ext('onPreHandler', async (request, h) => {
      const upload = request.payload && request.payload.file;
      if (!upload || !upload.path) return h.continue;

      let verdict;
      try {
        verdict = await scan(upload.path);
      } catch (err) {
        fs.unlinkSync(upload.path);
        return h.response({ error: 'Scan failed: ' + err.message }).code(500).takeover();
      }

      if (verdict === Verdict.Malicious) {
        fs.unlinkSync(upload.path);
        return h.response({ error: 'Malware detected.' }).code(400).takeover();
      }

      if (verdict === Verdict.ScanError && rejectScanError) {
        fs.unlinkSync(upload.path);
        return h.response({ error: 'Scan incomplete.' }).code(422).takeover();
      }

      return h.continue;
    });
  }
};

module.exports = pompelmiScanPlugin;

Register it when building the server:

await server.register({
  plugin: require('./plugins/pompelmiScan'),
  options: { rejectScanError: true }
});

Combining with Joi payload validation

Hapi's built-in Joi integration validates the payload schema before the route handler. Use it to enforce file type and size constraints before the scan runs.

const Joi = require('joi');

server.route({
  method:  'POST',
  path:    '/upload',
  options: {
    payload: {
      output:    'file',
      parse:     true,
      multipart: true,
      maxBytes:  10 * 1024 * 1024   // 10 MB max
    },
    validate: {
      payload: Joi.object({
        file: Joi.any().required()
      })
    }
  },
  handler: async (request, h) => {
    // Scan already ran in the onPreHandler extension
    const upload = request.payload.file;
    const dest   = path.join('/var/app/uploads', upload.filename);
    fs.renameSync(upload.path, dest);
    return { status: 'ok', file: upload.filename };
  }
});