Skip to content

CWE-434: Unrestricted Upload of File with Dangerous Type - JavaScript

Overview

Node.js applications typically handle uploads with the multer middleware. The most common mistake is validating inside fileFilter using file.mimetype or the extension of file.originalname - both come from the multipart request's part headers and are set by the client. multer does not verify either value against the file's actual bytes; it simply hands them to fileFilter for the application to decide.

The safe pattern checks the file's real bytes (magic numbers) after the upload completes, using a library such as file-type, and stores the result under a server-generated filename rather than file.originalname. Cap size with limits.fileSize in the multer configuration, and keep the storage directory outside anything passed to express.static().

Common Vulnerable Patterns

Trusting mimetype and originalname in fileFilter

const multer = require('multer');

// VULNERABLE - file.mimetype is a client-supplied multipart part header
const upload = multer({
  dest: 'public/uploads/', // VULNERABLE - inside a directory served by express.static
  fileFilter: (req, file, cb) => {
    const allowed = ['image/png', 'image/jpeg'];
    cb(null, allowed.includes(file.mimetype));
  },
});

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ path: req.file.path });
});

// Attack: multipart part sends Content-Type: image/png and
// filename="shell.php" but the body bytes are a PHP web shell.
// fileFilter only inspects the declared mimetype, which the attacker controls.

Why this is vulnerable: file.mimetype and file.originalname are copied out of the multipart headers before multer has read any of the file's content, and nothing in this handler inspects the bytes that actually arrived. Combined with dest: 'public/uploads/', an accepted script file becomes directly requestable.

Writing the Original Filename Under a Static Root

// VULNERABLE - uses file.originalname as the storage name, and 'public' is
// also the directory passed to express.static()
const storage = multer.diskStorage({
  destination: 'public/uploads/',
  filename: (req, file, cb) => cb(null, file.originalname),
});

Why this is vulnerable: If the storage engine reuses file.originalname and the destination is inside the tree served by express.static(), an attacker who gets any executable content past fileFilter can then request it directly by URL. file.originalname may also contain path separators, letting a crafted name write outside the configured destination.

Skipping Size Limits

// VULNERABLE - no limits configured; a very large upload can exhaust memory or disk
const upload = multer({ storage: multer.memoryStorage() });

Why this is vulnerable: multer.memoryStorage() buffers the entire file in RAM. Without limits.fileSize, a large or repeated upload can exhaust available memory before any type validation runs.

Secure Patterns

Magic-Byte Validation After Upload, Generated Filename, Storage Outside express.static

const multer = require('multer');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs/promises');

// SECURE - outside any path passed to express.static()
const UPLOAD_DIR = '/var/app-data/uploads';

// SECURE - allowlist of real content types the endpoint accepts
const ALLOWED_TYPES = new Set(['image/png', 'image/jpeg']);

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024 }, // SECURE - 5 MB cap before content validation
});

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

  // SECURE - detect the real type from the bytes, not the client-supplied
  // mimetype/extension. file-type is ESM-only from v17+, so import it
  // dynamically in a CommonJS handler.
  const { fileTypeFromBuffer } = await import('file-type');
  const detected = await fileTypeFromBuffer(req.file.buffer);
  if (!detected || !ALLOWED_TYPES.has(detected.mime)) {
    return res.status(400).send('Unsupported file type');
  }

  // SECURE - server-generated storage name; file.originalname is never used
  // to build a filesystem path
  const storedName = `${crypto.randomUUID()}.${detected.ext}`;
  const targetPath = path.join(UPLOAD_DIR, storedName);

  await fs.mkdir(UPLOAD_DIR, { recursive: true });
  await fs.writeFile(targetPath, req.file.buffer, { flag: 'wx' }); // fail if it exists
  res.json({ id: storedName });
});

Why this works: fileTypeFromBuffer reads the file's actual magic-byte signature, so the accept/reject decision rests on bytes the attacker cannot separate from the file's real content - a forged mimetype header has no effect. The stored filename is generated with crypto.randomUUID(), so file.originalname (traversal sequences, null bytes, double extensions) never reaches a filesystem path. flag: 'wx' refuses to overwrite an existing file on a name collision, and the upload directory sits outside anything express.static() serves, so even a file that reached disk cannot be executed through a direct request.

Serving Uploaded Files Back Safely

const STORED_NAME_PATTERN = /^[0-9a-f-]{36}\.(png|jpg)$/;

app.get('/files/:id', requireAuth, async (req, res) => {
  // SECURE - id is validated against the exact format the server generates,
  // then used only as a lookup key
  if (!STORED_NAME_PATTERN.test(req.params.id)) {
    return res.status(404).end();
  }
  if (!(await userCanAccess(req.user, req.params.id))) {
    return res.status(403).end();
  }

  const filePath = path.join(UPLOAD_DIR, req.params.id);
  res.set('X-Content-Type-Options', 'nosniff');
  res.download(filePath); // sets Content-Disposition: attachment
});

Why this works: res.download() sets Content-Disposition: attachment, so the browser downloads the file rather than rendering it inline, which prevents a stored file from executing as HTML/SVG/script even if something slipped past the upload-time check. Restricting id to the server's own generated format means it can only ever resolve to a file the application created.

Framework-Specific Guidance

Express with multer.diskStorage and a Generated Filename Function

// SECURE - diskStorage avoids buffering the whole file in memory, and the
// filename function generates the name instead of reusing file.originalname
const storage = multer.diskStorage({
  destination: (req, file, cb) => cb(null, UPLOAD_DIR),
  filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}.tmp`),
});

const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 },
});

app.post('/upload', upload.single('file'), async (req, res) => {
  const { fileTypeFromFile } = await import('file-type');
  const detected = await fileTypeFromFile(req.file.path);

  if (!detected || !ALLOWED_TYPES.has(detected.mime)) {
    await fs.unlink(req.file.path);
    return res.status(400).send('Unsupported file type');
  }

  const finalPath = `${req.file.path.replace(/\.tmp$/, '')}.${detected.ext}`;
  await fs.rename(req.file.path, finalPath);
  res.json({ id: path.basename(finalPath) });
});

Why this works: For larger files, diskStorage avoids holding the entire upload in memory the way memoryStorage() does, while the filename function still guarantees the on-disk name is server-generated. Validating with fileTypeFromFile after the write, then deleting the file on a validation failure, keeps invalid content from lingering in the upload directory.

Testing

  • Normal inputs: upload genuine PNG and JPEG files under the size limit; confirm both are accepted and retrievable.
  • Double extension: name a file report.pdf.php with real PDF bytes and with real PHP bytes; confirm acceptance depends on the detected magic bytes, not the filename.
  • MIME-type spoofing: set the multipart part's declared MIME type to image/png while the body is a script or executable; confirm rejection, since file.mimetype is never consulted for the accept/reject decision.
  • Path traversal: set originalname to ../../../etc/passwd and its URL-encoded form; confirm the stored path always resolves inside UPLOAD_DIR.
  • Oversized file: upload past limits.fileSize; confirm multer rejects the request (LIMIT_FILE_SIZE) before content validation runs.
  • Rescan: re-run any scanner or integration test against the fixed endpoint to confirm the finding no longer reproduces.

Common Pitfalls

  • Validating mimetype inside fileFilter and treating that as sufficient: fileFilter runs before multer has necessarily buffered the file content in a way the handler can inspect, and file.mimetype is client-supplied regardless of timing - it can only be used to reject obviously wrong requests early, never as the security decision.
  • Detecting content type but still writing under a static-served directory: adding file-type validation closes the "wrong file type" gap, but if the destination directory is still inside an express.static() root, any file that does pass validation (including a same-type file with malicious embedded content, such as an SVG with a script) remains directly reachable and renderable by URL.
  • Forgetting file-type is ESM-only from v17 onward: in a CommonJS codebase, require('file-type') throws; use a dynamic import('file-type') as shown above, or pin to a CommonJS-compatible major version deliberately.

Dependencies and Installation

npm install file-type

file-type is ESM-only starting at v17; the dynamic import() shown above works from CommonJS handlers. Keep multer and file-type current - both have had releases fixing security-relevant parsing bugs, so track them through normal dependency-update tooling rather than pinning indefinitely.

Additional Resources