File Upload Security

Accepting files means accepting attacker-controlled bytes into your storage, your processing pipeline, and possibly your origin.

File upload touches almost every category in this course at once: path traversal, command injection, XXE, XSS, resource exhaustion and access control. It deserves its own checklist.

Never trust anything the client tells you

  • The filename — path traversal, header injection, and a lie about the extension.
  • The Content-Type — set by the client, so image/png means nothing.
  • The extensionphoto.png.php, photo.php%00.png, or a double extension the web server resolves differently.
  • The size header — enforce the real limit at the stream, not from a header.

Verify the content

Read the magic bytes. A PNG starts with 89 50 4E 47; a file claiming to be a PNG that does not is rejected. Better still, re-encode: decode the image and write a fresh one, which destroys any embedded payload and any polyglot structure.

Store outside the web root, with generated names

The original filename is metadata you display; the storage key is a UUID you generate. That single decision eliminates path traversal, overwrite attacks and extension confusion together.

Never serve user content from your origin

A stored HTML or SVG file served from abc.com is stored XSS with access to your cookies. Serve user content from a separate domain, with Content-Disposition: attachment, X-Content-Type-Options: nosniff and a restrictive CSP.

Bound everything

Maximum file size, maximum count per request, maximum total per user, image dimension limits (a 50,000×50,000 PNG is a few hundred kilobytes and gigabytes of RAM when decoded), and a timeout on any processing step.

Scan and sandbox

Run antivirus if you accept documents. Run any media processing in a locked-down container — ImageMagick, ffmpeg and PDF libraries all have long CVE histories, and they are being fed hostile input by definition.

Example

Example · javascript
// The four lies a client tells about an upload
// 1. filename:     "../../../var/www/html/shell.php"
// 2. Content-Type: "image/png"        (it is a PHP script)
// 3. extension:    "avatar.png.php"   (or avatar.php%00.png)
// 4. size:         Content-Length says 1KB, the stream sends 5GB

// The four answers
// 1. generate the storage key yourself — never use their name for a path
// 2. read the magic bytes
// 3. derive the extension from the DETECTED type
// 4. enforce the limit on the stream, and abort when exceeded

When to use it

  • A web shell is prevented because uploads are stored outside the web root under generated names with no executable extension.
  • A decompression-bomb image is rejected by a pixel limit before the decoder can exhaust the container's memory.
  • An uploaded HTML file cannot steal sessions because user content is served from a separate domain with Content-Disposition: attachment.

More examples

A complete, defensible upload handler

Re-encoding is the single highest-value step: it neutralises polyglot files, strips EXIF GPS data, and guarantees the stored bytes are a real image.

Example · javascript
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';
import crypto from 'node:crypto';

// 1. Memory storage with a hard limit — nothing touches disk unverified.
const upload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 5 * 1024 * 1024,   // 5MB, enforced on the STREAM
    files: 1,
    fields: 10,
    parts: 15,
  },
});

// 2. Allowlist of DETECTED types, mapped to the extension we will use.
const ALLOWED = {
  'image/jpeg': 'jpg',
  'image/png': 'png',
  'image/webp': 'webp',
  // Deliberately absent: image/svg+xml (it is XML), application/pdf,
  // text/html — each is active content.
};

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

  // 3. Detect the REAL type from magic bytes. Ignore the client entirely.
  const detected = await fileTypeFromBuffer(req.file.buffer);
  if (!detected || !ALLOWED[detected.mime]) {
    return res.status(415).json({
      error: 'unsupported_file_type',
      allowed: Object.keys(ALLOWED),
    });
  }

  // 4. Bound the DECODED size, not just the byte size.
  let processed;
  try {
    const image = sharp(req.file.buffer, {
      limitInputPixels: 50_000_000,   // ~7000x7000; blocks decompression bombs
      sequentialRead: true,
    });
    const meta = await image.metadata();
    if (meta.width > 10_000 || meta.height > 10_000) {
      return res.status(400).json({ error: 'image_dimensions_too_large' });
    }
    if ((meta.pages ?? 1) > 100) {     // animated formats
      return res.status(400).json({ error: 'too_many_frames' });
    }

    // 5. RE-ENCODE. This destroys embedded payloads, polyglots and EXIF —
    //    and EXIF routinely contains GPS coordinates users did not intend
    //    to publish.
    processed = await image
      .rotate()                        // apply EXIF orientation, then drop it
      .resize(512, 512, { fit: 'cover' })
      .jpeg({ quality: 85, mozjpeg: true })
      .toBuffer();
  } catch (err) {
    logger.warn({ err, userId: req.user.id }, 'image processing failed');
    return res.status(400).json({ error: 'invalid_image' });
  }

  // 6. WE choose the storage key. The user's filename is display metadata only.
  const key = `avatars/${req.user.id}/${crypto.randomUUID()}.jpg`;

  await s3.putObject({
    Bucket: USER_CONTENT_BUCKET,       // a bucket with NO public read policy
    Key: key,
    Body: processed,
    ContentType: 'image/jpeg',
    ContentDisposition: 'attachment',  // never render inline
    CacheControl: 'private, max-age=86400',
    ServerSideEncryption: 'AES256',
  });

  await db('users').where({ id: req.user.id }).update({ avatar_key: key });

  res.status(201).json({
    url: await signedUrl(key, { expiresIn: 3600 }),
    // The original name, sanitised, purely for display
    originalName: sanitiseFilename(req.file.originalname),
  });
});

const sanitiseFilename = (n) =>
  String(n).replace(/[^\w.\- ]/g, '_').replace(/\.{2,}/g, '.').slice(0, 100);

Serving user content without giving away your origin

ResponseContentDisposition on the signed URL is a useful belt-and-braces: it forces download behaviour even if something was stored with the wrong metadata.

Example · javascript
// The rule: user-uploaded content NEVER shares an origin with your application.

// ── Best: a separate domain, with signed URLs ─────────────────────────
//   app:          https://abc.com
//   user content: https://usercontent-abc.com     ← a DIFFERENT SITE
//
// Even if an HTML or SVG file executes, it is on an origin with no cookies,
// no localStorage, and no access to your DOM.

export async function signedUrl(key, { expiresIn = 300 } = {}) {
  return s3.getSignedUrlPromise('getObject', {
    Bucket: USER_CONTENT_BUCKET,
    Key: key,
    Expires: expiresIn,
    // Force these on the RESPONSE, whatever is stored:
    ResponseContentDisposition: 'attachment',
    ResponseContentType: 'application/octet-stream',
  });
}

// ── If you must serve it yourself ─────────────────────────────────────
app.get('/files/:id', auth, async (req, res) => {
  const file = await db('files')
    .where({ id: req.params.id, user_id: req.user.id })   // authorization
    .first();
  if (!file) return res.status(404).json({ error: 'not_found' });

  res.set({
    // Serve the type WE detected, never the one the client claimed
    'Content-Type': file.detected_mime,
    'X-Content-Type-Options': 'nosniff',        // no MIME sniffing
    'Content-Disposition':
      `attachment; filename="${sanitiseFilename(file.original_name)}"`,
    'Content-Security-Policy': "default-src 'none'; sandbox",
    'X-Frame-Options': 'DENY',
    'Cache-Control': 'private, no-store',
  });

  createReadStream(absolutePathFor(file.storage_key)).pipe(res);
});

// ── The bucket policy is part of the control ─────────────────────────
// {
//   "Effect": "Deny",
//   "Principal": "*",
//   "Action": "s3:GetObject",
//   "Resource": "arn:aws:s3:::usercontent/*",
//   "Condition": { "Null": { "aws:UserAgent": "false" } }
// }
// Simply: no public read. Every access goes through a signed URL your
// application issued after checking authorization.

// ── And never do this ────────────────────────────────────────────────
// app.use('/uploads', express.static('./uploads'));
// Directory listing, no authorization, your origin, and whatever the web
// server decides to execute.

Quotas, scanning and the processing sandbox

The internal network on the processing container is the key line: a compromised decoder cannot exfiltrate what it reads because it has no route out.

Example · javascript
// ── Per-user quotas, or storage becomes a denial-of-wallet attack ─────
async function checkQuota(userId, incomingBytes) {
  const { total, count } = await db('files')
    .where({ user_id: userId })
    .select(db.raw('COALESCE(SUM(size_bytes),0) as total, COUNT(*) as count'))
    .first();

  const plan = await planFor(userId);
  if (Number(total) + incomingBytes > plan.storageBytes) {
    throw new PayloadTooLargeError('storage_quota_exceeded');
  }
  if (Number(count) >= plan.maxFiles) {
    throw new PayloadTooLargeError('file_count_quota_exceeded');
  }
}

// Rate-limit uploads separately — they are far more expensive than a read.
const uploadLimiter = rateLimit({
  windowMs: 3600_000, limit: 100,
  keyGenerator: (req) => `upload:${req.user.id}`,
});

// ── Malware scanning for document uploads ────────────────────────────
import NodeClam from 'clamscan';
const clam = await new NodeClam().init({ clamdscan: { socket: '/var/run/clamav/clamd.sock' } });

async function scan(buffer) {
  const { isInfected, viruses } = await clam.scanBuffer(buffer, 30_000);
  if (isInfected) {
    logger.error({ viruses }, 'infected upload rejected');
    throw new BadRequestError('file_rejected');   // do not name the signature
  }
}

// ── Process media in a sandbox ───────────────────────────────────────
// ImageMagick, ffmpeg, LibreOffice and PDF libraries all have long CVE
// histories, and you are feeding them hostile input by definition.
//
// docker-compose.yml
//   media-processor:
//     image: media-tools:latest
//     read_only: true                 # nothing writable but /tmp
//     tmpfs: [/tmp:size=256m]
//     cap_drop: [ALL]
//     security_opt: [no-new-privileges:true]
//     networks: [processing-net]      # internal: true → NO egress
//     mem_limit: 512m
//     pids_limit: 64
//     cpus: 0.5
//
// The container is the boundary. A CVE in the decoder then reaches a
// throwaway process with no network, no secrets and no filesystem.

// ── And expire what nobody claimed ───────────────────────────────────
// Orphaned uploads accumulate forever. A nightly job deleting unreferenced
// objects older than 24 hours is both a cost and a privacy control.

Discussion

  • Be the first to comment on this lesson.