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.
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 };
}
});