Integrating antivirus scanning in Nuxt.js

Nuxt 3 server routes live under server/api/ and are handled by the h3 framework — the same HTTP toolkit that powers Nitro, the engine behind every Nuxt 3 deployment. h3 provides a readMultipartFormData utility that makes it straightforward to access uploaded files on the server side.

This guide integrates pompelmi into a Nuxt 3 server route so that every uploaded file is scanned by ClamAV before it is stored or returned to the client.

pompelmi requires the Node.js preset — it uses child_process and net. The default Nuxt 3 preset is already Node.js for self-hosted deployments. Edge or Cloudflare Workers presets are not compatible.

Install

npm install pompelmi

Install ClamAV on your development machine or server:

Linux (Debian / Ubuntu)
sudo apt-get install -y clamav && sudo freshclam
macOS
brew install clamav && freshclam

Nuxt 3 server routes

Server routes in Nuxt 3 are TypeScript (or JavaScript) files placed in the server/api/ directory. The filename sets the HTTP method: upload.post.ts handles POST /api/upload.

Each file must export a default handler created with defineEventHandler from h3.

Reading multipart form data

h3's readMultipartFormData parses a multipart request and returns an array of MultipartPart objects, each with:

  • name — the form field name
  • filename — the original file name (present for file fields)
  • data — a Buffer with the raw file bytes
  • type — the MIME type declared by the client

pompelmi needs a file on disk. Write the data buffer to a temp path, scan it, then clean up.

Complete server route

// server/api/upload.post.ts
import { defineEventHandler, readMultipartFormData, createError } from 'h3';
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';

export default defineEventHandler(async (event) => {
  let tmpPath: string | null = null;

  try {
    const parts = await readMultipartFormData(event);

    if (!parts || parts.length === 0) {
      throw createError({ statusCode: 400, message: 'No file provided.' });
    }

    const filePart = parts.find((p) => p.name === 'file');

    if (!filePart?.data || !filePart.filename) {
      throw createError({ statusCode: 400, message: 'No file field found in form.' });
    }

    // Enforce upload size limit
    const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
    if (filePart.data.length > MAX_BYTES) {
      throw createError({ statusCode: 413, message: 'File too large.' });
    }

    // Write to temp file for scanning
    const ext = filePart.filename.split('.').pop()?.toLowerCase() ?? 'bin';
    tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);
    await writeFile(tmpPath, filePart.data);

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      throw createError({
        statusCode: 400,
        message: 'Malware detected. Upload rejected.',
      });
    }

    if (verdict === Verdict.ScanError) {
      throw createError({
        statusCode: 422,
        message: 'Scan could not complete. Upload rejected as a precaution.',
      });
    }

    // File is clean — persist to permanent storage here
    // e.g. await uploadToS3(filePart.data, filePart.filename);

    return {
      status: 'ok',
      filename: filePart.filename,
      size: filePart.data.length,
    };

  } finally {
    // Always clean up the temp file
    if (tmpPath && existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
});
Test with EICAR — the harmless standard antivirus test string — to confirm your pipeline is working before deploying to production:
# Expects HTTP 400 with "Malware detected"
echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' \
  > /tmp/eicar.txt
curl -F "file=@/tmp/eicar.txt" http://localhost:3000/api/upload

Reusable scan utility

If you have multiple upload endpoints, extract the scan logic into a shared server utility:

// server/utils/scanFile.ts
import { scan, Verdict } from 'pompelmi';
import { writeFile, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { randomBytes } from 'crypto';
import { createError } from 'h3';

export async function scanBuffer(
  data: Buffer,
  filename: string
): Promise<void> {
  const ext = filename.split('.').pop()?.toLowerCase() ?? 'bin';
  const tmpPath = join(tmpdir(), randomBytes(16).toString('hex') + '.' + ext);

  try {
    await writeFile(tmpPath, data);
    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      throw createError({ statusCode: 400, message: 'Malware detected. Upload rejected.' });
    }
    if (verdict === Verdict.ScanError) {
      throw createError({ statusCode: 422, message: 'Scan incomplete. Upload rejected.' });
    }
  } finally {
    if (existsSync(tmpPath)) {
      await unlink(tmpPath).catch(() => {});
    }
  }
}

Nuxt auto-imports from server/utils/, so you can call it directly:

// server/api/documents.post.ts
import { defineEventHandler, readMultipartFormData, createError } from 'h3';

export default defineEventHandler(async (event) => {
  const parts = await readMultipartFormData(event);
  const file  = parts?.find((p) => p.name === 'file');
  if (!file?.data || !file.filename) {
    throw createError({ statusCode: 400, message: 'No file.' });
  }

  await scanBuffer(file.data, file.filename); // throws on malware / error

  // Proceed with clean file …
  return { status: 'ok' };
});

Next steps