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.
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 freshclammacOS
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 namefilename— the original file name (present for file fields)data— aBufferwith the raw file bytestype— 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(() => {});
}
}
});
# 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
- Using Nuxt with Docker Compose? See Running pompelmi with ClamAV in Docker Compose.
- Deploying to Kubernetes? See Setting up pompelmi with ClamAV on Kubernetes for the TCP-mode approach.
- Want a broader view of upload security? Read Node.js file upload security checklist.