Using pompelmi in a Koa.js middleware pipeline

Koa's middleware model — composable async functions that pass control via await next() — maps cleanly onto a scanning pipeline. Add an antivirus middleware before your route handler, and any malicious upload is rejected before business logic runs.

This guide uses koa-body for multipart parsing with disk storage, and pompelmi for ClamAV-backed scanning.

ClamAV must be installed and freshclam run before this will work. See the Quickstart.

Install

npm install koa koa-body @koa/router pompelmi

koa-body setup

Configure koa-body to write multipart uploads to a directory on disk. Without formidable.uploadDir, uploads are buffered in memory and pompelmi would have nothing to scan.

const Koa     = require('koa');
const koaBody = require('koa-body');
const os      = require('os');

const app = new Koa();

app.use(koaBody({
  multipart: true,
  formidable: {
    uploadDir:    os.tmpdir(),      // Write uploads to OS temp directory
    keepExtensions: true,           // Preserve file extension in temp name
    maxFileSize:    50 * 1024 * 1024  // 50 MB limit
  }
}));

Basic upload route

After koa-body runs, ctx.request.files.file contains a formidable File object with a .filepath property.

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

app.use(async (ctx) => {
  if (ctx.method !== 'POST' || ctx.path !== '/upload') return;

  const file = ctx.request.files && ctx.request.files.file;
  if (!file) {
    ctx.status = 400;
    ctx.body   = { error: 'No file provided.' };
    return;
  }

  const tmpPath = file.filepath;

  try {
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      fs.unlinkSync(tmpPath);
      ctx.status = 400;
      ctx.body   = { error: 'Malware detected. Upload rejected.' };
      return;
    }

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

    const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
    fs.renameSync(tmpPath, dest);
    ctx.body = { status: 'ok', file: dest };

  } catch (err) {
    if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
    ctx.status = 500;
    ctx.body   = { error: err.message };
  }
});

app.listen(3000);

Reusable scan middleware

Extract the scanning logic into a standalone Koa middleware function. This middleware short-circuits with an error response on rejection; on a clean result it calls await next() and stores file metadata on the context state for downstream handlers.

// middleware/scanUpload.js
const { scan, Verdict } = require('pompelmi');
const fs = require('fs');

/**
 * Koa middleware — scans ctx.request.files.file with pompelmi.
 * Sets ctx.state.uploadedFile on success.
 * Calls next() only when the file is Clean.
 */
async function scanUpload(ctx, next) {
  const file = ctx.request.files && ctx.request.files.file;
  if (!file) {
    ctx.status = 400;
    ctx.body   = { error: 'No file provided.' };
    return;
  }

  let verdict;
  try {
    verdict = await scan(file.filepath);
  } catch (err) {
    if (fs.existsSync(file.filepath)) fs.unlinkSync(file.filepath);
    ctx.status = 500;
    ctx.body   = { error: 'Scan failed: ' + err.message };
    return;
  }

  if (verdict === Verdict.Malicious) {
    fs.unlinkSync(file.filepath);
    ctx.status = 400;
    ctx.body   = { error: 'Malware detected.' };
    return;
  }

  if (verdict === Verdict.ScanError) {
    fs.unlinkSync(file.filepath);
    ctx.status = 422;
    ctx.body   = { error: 'Scan incomplete.' };
    return;
  }

  ctx.state.uploadedFile = file;
  await next();
}

module.exports = { scanUpload };

Using with @koa/router

Apply the middleware per-route using @koa/router's route-level middleware array.

const Router = require('@koa/router');
const { scanUpload } = require('./middleware/scanUpload');
const path = require('path');
const fs   = require('fs');

const router = new Router();

router.post('/upload', scanUpload, async (ctx) => {
  // scanUpload already verified the file is Clean
  const file = ctx.state.uploadedFile;
  const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
  fs.renameSync(file.filepath, dest);
  ctx.body = { status: 'ok', file: dest };
});

router.post('/documents', scanUpload, async (ctx) => {
  const file = ctx.state.uploadedFile;
  const dest = path.join('/var/app/documents', file.originalFilename || file.newFilename);
  fs.renameSync(file.filepath, dest);
  ctx.body = { status: 'ok', file: dest };
});

app.use(router.routes());
app.use(router.allowedMethods());

Scanning multiple files

koa-body places multiple files in an array under the field name. Enable formidable.multiples: true and iterate:

app.use(koaBody({
  multipart: true,
  formidable: {
    uploadDir:  os.tmpdir(),
    multiples:  true,           // Allow multiple files per field
    keepExtensions: true
  }
}));

router.post('/upload-many', async (ctx) => {
  const rawFiles = ctx.request.files && ctx.request.files.files;
  const files    = Array.isArray(rawFiles) ? rawFiles : [rawFiles].filter(Boolean);

  if (!files.length) {
    ctx.status = 400;
    ctx.body   = { error: 'No files provided.' };
    return;
  }

  const accepted = [];

  for (const file of files) {
    const verdict = await scan(file.filepath);

    if (verdict !== Verdict.Clean) {
      files.forEach(f => { try { fs.unlinkSync(f.filepath); } catch (_) {} });
      ctx.status = verdict === Verdict.Malicious ? 400 : 422;
      ctx.body   = {
        error: verdict === Verdict.Malicious
          ? 'Malware detected in ' + file.originalFilename
          : 'Scan incomplete for ' + file.originalFilename
      };
      return;
    }

    const dest = path.join('/var/app/uploads', file.originalFilename || file.newFilename);
    fs.renameSync(file.filepath, dest);
    accepted.push(dest);
  }

  ctx.body = { status: 'ok', files: accepted };
});