How to handle encrypted and password-protected files during scan

Encryption is a well-known antivirus evasion technique. An attacker can take any malicious file, compress it into a password-protected ZIP archive, and upload it to your application. Because the archive is encrypted, the antivirus scanner cannot see the contents — the file may pass as clean.

This is not a flaw in pompelmi or ClamAV. No antivirus engine can scan inside an encrypted file without the key. The question is how your application should respond.

How ClamAV behaves with encrypted files

The behaviour depends on the file type:

File type ClamAV behaviour pompelmi Verdict
Password-protected ZIP Detects the encryption and reports it as encrypted. Does not scan contents. Verdict.ScanError
Password-protected RAR Same as ZIP — detects encryption, cannot scan contents. Verdict.ScanError
Password-protected 7-Zip (.7z) Detects encryption, reports it, cannot scan contents. Verdict.ScanError
Encrypted Office doc (password set via Office) Office password protection uses OOXML encryption. ClamAV cannot read the contents — may return Clean (false negative). Verdict.Clean (unreliable)
PDF with encryption (user password) ClamAV reads the PDF structure but cannot access encrypted content streams. Verdict.Clean (unreliable)
The most dangerous case is encrypted Office documents. ClamAV may return Verdict.Clean — not because the file is safe, but because it cannot see inside the encryption. Do not store or serve encrypted Office files without additional controls.

Understanding the Verdict.ScanError for archives

When ClamAV encounters a password-protected archive, it exits with code 2 (scan error). pompelmi maps this to Verdict.ScanError. This is the correct and expected behaviour — the file is not confirmed safe, so it should not be passed through.

If you already follow the standard pompelmi handling pattern — rejecting Verdict.ScanError — you are already protected against password-protected archives:

const verdict = await scan(tmpPath);

if (verdict === Verdict.Malicious) {
  // Confirmed threat
  return res.status(400).json({ error: 'Malware detected.' });
}

if (verdict === Verdict.ScanError) {
  // Includes: encrypted archives, corrupted files, scan timeouts
  return res.status(422).json({ error: 'File could not be scanned. Upload rejected.' });
}

// Verdict.Clean — proceed

Detecting encrypted Office documents

Because ClamAV may return Verdict.Clean for encrypted Office files, you need an additional check. Encrypted Office documents (created with a password in Microsoft Office) use the OOXML encryption wrapper format. This format stores the encrypted document inside an OLE2 compound file — meaning the file starts with the OLE2 magic bytes D0 CF 11 E0, even though the content is an OOXML document.

A practical heuristic: if the uploaded file has extension .xlsx, .docx, or .pptx but its magic bytes identify it as OLE2 (not ZIP), it is likely encrypted.

function isEncryptedOffice(filePath, originalName) {
  const fd  = require('fs').openSync(filePath, 'r');
  const buf = Buffer.alloc(4);
  require('fs').readSync(fd, buf, 0, 4, 0);
  require('fs').closeSync(fd);

  const isOle2  = buf[0] === 0xD0 && buf[1] === 0xCF;  // OLE2 magic
  const ext     = originalName.split('.').pop()?.toLowerCase();
  const ooxml   = ['docx', 'xlsx', 'pptx', 'docm', 'xlsm'];

  // OOXML extensions with OLE2 magic = almost certainly encrypted
  return isOle2 && ooxml.includes(ext);
}
This heuristic also catches legitimate .doc / .xls (legacy binary) files. If you want to accept those, check the extension list. Generally, rejecting all OLE2 files with OOXML extensions is the safer policy.

Policy options

There is no universally correct policy. Choose based on your application's needs:

Policy Pros Cons
Reject all encrypted files.
Reject on ScanError and detect encrypted Office docs separately.
Maximum security. No encrypted file ever enters storage. Legitimate users with password-protected documents cannot upload them.
Accept encrypted files with warning.
Tag them as "unverified" and restrict access until manually reviewed.
Supports legitimate use cases (e.g. secure document sharing). Requires a manual review workflow. Encrypted malware can enter storage temporarily.
Accept only specific encrypted types.
Allow password-protected PDFs but reject encrypted Office docs.
Balanced approach for specific use cases. Requires per-format detection logic. PDFs may still carry threats.

For most web applications the recommended policy is the first: reject all files that cannot be fully scanned.

Complete upload handler with encrypted file handling

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

const app    = express();
const upload = multer({
  dest:   os.tmpdir(),
  limits: { fileSize: 50 * 1024 * 1024 },
});

function readMagic(filePath) {
  const fd  = fs.openSync(filePath, 'r');
  const buf = Buffer.alloc(4);
  fs.readSync(fd, buf, 0, 4, 0);
  fs.closeSync(fd);
  return buf;
}

function isEncryptedOfficeDocument(filePath, originalName) {
  const magic  = readMagic(filePath);
  const isOle2 = magic[0] === 0xD0 && magic[1] === 0xCF;
  const ext    = originalName.split('.').pop()?.toLowerCase();
  const ooxml  = new Set(['docx', 'xlsx', 'pptx', 'docm', 'xlsm', 'pptm']);
  return isOle2 && ooxml.has(ext);
}

app.post('/upload', upload.single('file'), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file provided.' });

  const tmpPath = req.file.path;

  try {
    // Pre-check: reject encrypted Office documents before scanning
    if (isEncryptedOfficeDocument(tmpPath, req.file.originalname)) {
      return res.status(400).json({
        error: 'Encrypted or password-protected Office files cannot be accepted. ' +
               'Please remove the password before uploading.',
      });
    }

    const verdict = await scan(tmpPath);

    if (verdict === Verdict.Malicious) {
      return res.status(400).json({ error: 'Malware detected. Upload rejected.' });
    }

    if (verdict === Verdict.ScanError) {
      // This catches password-protected archives (ZIP, RAR, 7z)
      // as well as corrupted files and scan failures
      return res.status(422).json({
        error: 'File could not be fully scanned — it may be encrypted, ' +
               'password-protected, or corrupted. Upload rejected.',
      });
    }

    // Verdict.Clean — file was fully scannable and no threats found
    return res.json({ status: 'ok', file: req.file.originalname });

  } finally {
    if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath);
  }
});
Return a clear, user-friendly error message that explains why the file was rejected and what the user can do. "Remove the password before uploading" is actionable. "Scan error" is not.

Next steps