GUIDE #001 · FILE UPLOADS

The file says it's a photo. Should you believe it?

Every file you accept from a stranger carries two labels an attacker can write themselves, and one signature they can't fake nearly as easily. Peel back these three "photos" to see what's actually inside.

Step 1

What a MIME type actually is

MIME stands for Multipurpose Internet Mail Extensions. It's an old email standard whose labeling scheme got borrowed by the entire web. It's just a string of the form type/subtype that says "here's what kind of content follows."

When a browser uploads a file, it doesn't inspect the file's contents to figure this out. It usually just looks at the file extension on your computer and guesses. That guess gets sent as an HTTP header called Content-Type. Hover each one:

The filename extension

.jpg, .png, .pdf. Just characters after a dot in a string the uploader typed or their OS assigned. Nothing enforces that it matches the content.

The Content-Type header

A text field the browser or the attacker's script sets when building the upload request. In a raw HTTP request, this is just another value someone can type in. Like writing "fragile: glass" on a box that's actually full of bricks.

Both of these are just claims. Nothing about sending a file forces the extension or the header to match what's actually inside it. That's the entire vulnerability, in one sentence.

Step 2

What actually can't lie: the bytes

Real image and document formats begin with a fixed sequence of bytes called a file signature or magic number, written by the software that actually encoded the file, not typed by whoever's uploading it. A real PNG encoder always writes the same 8 bytes at the very start, every time, because that's part of the PNG spec.

Click a format to see its real opening bytes:

This is what "validate by actual content type" means in practice: read the first few bytes of the uploaded file on your server, and check them against a known signature, instead of trusting whatever extension or header the client sent along with it.

⚑ One honest caveat

Magic-number checking proves a file starts like a real PNG/JPEG/etc. It doesn't prove the rest of the file is harmless. "Polyglot" files can be technically-valid images with extra malicious data appended. That's why formats like SVG (which is just text/XML and can embed <script>) get special treatment below. Signature checks alone aren't enough for text-based formats.

Step 3 · from a real code review

How this becomes a real attack

This pattern showed up during a review of our upload endpoint. I didn't get why it mattered until I traced the full chain:

Notice where the trust boundary breaks: the only check was file.type.startsWith('image/') inside Angular. That's client-side JavaScript the attacker simply doesn't run. Everything after that trusted the attacker's own claims.

Step 4 · try it yourself

Border control

Five files just arrived at your upload endpoint, each claiming to be an image. Scan each one to see its real opening bytes, then decide: allow it through, or deny it. Rule: only genuine binary image formats (PNG / JPEG / GIF) get in.

Score: 0 / 5
Step 5

Fixing upload/simple for real

There are three layers of "type checking," and only the last one is trustworthy:

Layer 1 · Extension

Useless as a security control

Renaming shell.php to photo.jpg takes one second. A sanitizeFileName helper that preserves whatever extension the uploader chose was never a real check to begin with.

Layer 2 · Client-declared Content-Type

Still just a claim, even on the server

In Multer, file.mimetype is not detected. It's read straight from the Content-Type part of the multipart form the client built. curl -F "file=@payload.html;type=image/png" makes Multer report image/png for an HTML file. Easy to miss if you're trusting that field for S3 ContentType.

Layer 3 · Server-side content sniffing

The only layer that checks the actual bytes

Read the uploaded buffer yourself and compare its real signature against an allowlist, using a library like file-type, before you ever write it to storage.

Before / after for my simpleUpload flow:

// BEFORE: files.controller.ts
@Post('upload/simple')
@UseInterceptors(FileInterceptor('file'))  // no fileFilter, no limits
async simpleUpload(@UploadedFile() file: Express.Multer.File) {
  return await this.useCaseRunner.run(UploadFileUseCase, { file });
}
// AFTER: reject early on size + a real content check
import { fileTypeFromBuffer } from 'file-type';

const ALLOWED = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']);

@Post('upload/simple')
@UseInterceptors(FileInterceptor('file', {
  limits: { fileSize: 10 * 1024 * 1024 },
}))
async simpleUpload(@UploadedFile() file: Express.Multer.File) {
  // don't trust file.mimetype; sniff the real bytes
  const detected = await fileTypeFromBuffer(file.buffer);
  if (!detected || !ALLOWED.has(detected.mime)) {
    throw new BadRequestException('File content does not match an allowed image type');
  }
  // store using the DETECTED mime, never file.mimetype
  return await this.useCaseRunner.run(UploadFileUseCase, { file, detectedType: detected.mime });
}

Don't stop at the filter

In S3-Storage.ts, stop writing ContentType: file.mimetype. Use the sniffed type instead. And reconsider ACL: "public-read" for user uploads by default.

SVG is a special case

SVG is XML text. A valid SVG can legally contain <script>. If you must accept it, sanitize it server-side (strip scripts/event handlers) or serve it with a Content-Disposition: attachment and X-Content-Type-Options: nosniff so browsers won't render it inline.

Recap

The checklist for any upload endpoint

This is the approach that made sense once I understood the problem. Hopefully you see why each line matters, not just that it compiles.