Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,32 @@ This style ensures that there is no ambiguity with global npm packages and makes

<p align="right"><a href="#table-of-contents">⬆ Return to top</a></p>

## ![✔] 6.28. Scan uploaded files for malware before storing them
### `🌟 #new`

Comment on lines +1394 to +1396
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new best practice isn't linked from the Table of Contents (and the Security Practices count remains unchanged). Add a TOC entry for 6.28 and update the security section count so readers can discover it.

Copilot uses AI. Check for mistakes.
<a href="https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload" target="_blank"><img src="https://img.shields.io/badge/%E2%9C%94%20OWASP%20–%20Unrestricted%20File%20Upload-green.svg" alt=""/></a>

**TL;DR:** Validate uploaded files server-side against a malware engine before writing them to disk or object storage. Filename and MIME type come from the client and are trivially spoofed — only inspecting the actual bytes is reliable. ClamAV is the standard open-source engine for this; wrap it at the route level so a clean verdict is required before any storage operation proceeds:

```javascript
const { scan, Verdict } = require('pompelmi'); // ClamAV wrapper for Node.js

app.post('/upload', upload.single('file'), async (req, res) => {
const result = await scan(req.file.path);

if (result !== Verdict.Clean) {
await fs.unlink(req.file.path);
return res.status(422).json({ error: 'File rejected', verdict: result.description });
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snippet returns verdict: result.description, but the longer example treats the scan result as an enum (result === Verdict.Malicious/ScanError). Unless pompelmi actually returns an object with a description field, this will be undefined/misleading—prefer returning the verdict value itself (or a known mapping) and keep the examples consistent.

Suggested change
return res.status(422).json({ error: 'File rejected', verdict: result.description });
return res.status(422).json({ error: 'File rejected', verdict: result });

Copilot uses AI. Check for mistakes.
}

// safe to move to permanent storage
});
```

**Otherwise:** Malicious files stored on your infrastructure can be served to other users, exploit vulnerabilities in downstream parsers (PDF renderers, image processors, archive extractors), or act as a staging point for further attacks

Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README entry introduces a dedicated details file (sections/security/scanfilesuploads.md) but doesn't include a "Read More" link like most other security practices with dedicated section files. Add a link to keep navigation consistent and avoid orphaning the new document.

Suggested change
[Read More: Scan files before upload or storage](sections/security/scanfilesuploads.md)

Copilot uses AI. Check for mistakes.
<br/><br/>

# `7. Draft: Performance Best Practices`

## Our contributors are working on this section. [Would you like to join?](https://github.com/goldbergyoni/nodebestpractices/issues/256)
Expand Down
73 changes: 73 additions & 0 deletions sections/security/scanfilesuploads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Scan uploaded files for malware before storing them

### One Paragraph Explainer

File upload endpoints are a common attack vector. A file that appears harmless
at the HTTP layer can carry a known malware signature, an embedded script, or
a crafted archive designed to exploit downstream consumers. Validating the
filename or MIME type alone is not enough — those values come from the client
and are trivially spoofed. The only reliable check happens on the file bytes,
server-side, before the file reaches permanent storage.

ClamAV is the standard open-source antivirus engine for server-side scanning.
Integrating it at the upload route means every file is inspected in memory
before it is written to disk, S3, or any other store.
Comment on lines +13 to +14
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explainer says files are scanned "in memory" before being written to disk, but the example uses multer({ dest: os.tmpdir() }), which writes the upload to disk first. Either adjust the text to describe scanning a temporary file before moving to permanent storage, or change the example to use in-memory storage (e.g., multer.memoryStorage()) and scan the buffer.

Suggested change
Integrating it at the upload route means every file is inspected in memory
before it is written to disk, S3, or any other store.
Integrating it at the upload route means every file is inspected server-side
after upload to a temporary location and before it is moved to permanent
storage such as your uploads directory, S3, or any other store.

Copilot uses AI. Check for mistakes.

### Code Example – scanning an upload in Express before saving

```javascript
const multer = require('multer');
const { scan, Verdict } = require('pompelmi');
const path = require('path');
const os = require('os');
const fs = require('fs/promises');

const upload = multer({ dest: os.tmpdir() });

app.post('/upload', upload.single('file'), async (req, res) => {
const tmpPath = req.file.path;

try {
const result = await scan(tmpPath);

if (result === Verdict.Malicious) {
await fs.unlink(tmpPath);
return res.status(422).json({ error: 'File rejected: malware detected' });
}

if (result === Verdict.ScanError) {
await fs.unlink(tmpPath);
return res.status(422).json({ error: 'Scan incomplete: file rejected as precaution' });
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict.ScanError is a server-side scanning failure, but the example returns HTTP 422 (client error). Consider using 503 (or 500) to reflect a temporary/internal failure while still failing closed (rejecting the file).

Suggested change
return res.status(422).json({ error: 'Scan incomplete: file rejected as precaution' });
return res.status(503).json({ error: 'Scan incomplete: file rejected as precaution' });

Copilot uses AI. Check for mistakes.
}

// Verdict.Clean — move to permanent storage
await fs.rename(tmpPath, path.join('./uploads', req.file.originalname));
res.json({ status: 'ok' });
Comment on lines +44 to +45
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using req.file.originalname directly to build the destination path can allow path traversal (e.g., filenames containing ../) and makes overwrites/collisions likely. Prefer generating a server-side filename (UUID/content hash) and/or sanitizing with path.basename, and keep the original name only as metadata.

Copilot uses AI. Check for mistakes.
} catch (err) {
await fs.unlink(tmpPath).catch(() => {});
res.status(500).json({ error: 'Upload failed' });
}
});
```

### What to look for in a scanning library

- **Typed verdicts** — distinguish `Clean`, `Malicious`, and `ScanError` (scan
failure should fail closed, not silently pass)
- **No daemon required** — simpler ops; `clamscan` CLI is enough for most
upload volumes
- **Zero runtime dependencies** — nothing to audit beyond ClamAV itself
- **Works in Docker** — ClamAV can run in a sidecar container and be reached
via TCP socket
Comment on lines +57 to +61
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library-selection bullets conflict: "No daemon required" suggests using clamscan only, but the next bullet recommends reaching ClamAV via TCP socket (which typically implies clamd). Reword to clarify that both CLI and daemon modes are acceptable options depending on throughput/ops needs.

Suggested change
- **No daemon required** — simpler ops; `clamscan` CLI is enough for most
upload volumes
- **Zero runtime dependencies** — nothing to audit beyond ClamAV itself
- **Works in Docker** — ClamAV can run in a sidecar container and be reached
via TCP socket
- **Supports simple CLI mode** — for lower upload volumes, invoking
`clamscan` directly keeps ops simple and avoids requiring a daemon
- **Zero runtime dependencies** — nothing to audit beyond ClamAV itself
- **Also supports daemon/container deployments** — for higher throughput or
Docker sidecar setups, ClamAV can run as `clamd` and be reached via TCP
socket

Copilot uses AI. Check for mistakes.

### Otherwise

Malicious files stored on your infrastructure can be served to other users,
trigger vulnerabilities in downstream parsers (PDF renderers, image processors,
archive extractors), or be used as staging for further attacks.

### External references

- 🔗 [ClamAV official documentation](https://docs.clamav.net/)
- 🔗 [OWASP – Unrestricted File Upload](https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload)
- 🔗 [pompelmi – ClamAV wrapper for Node.js](https://github.com/pompelmi/pompelmi)
Loading