Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Never Trust the Filename

  • security
  • laravel
  • typescript
  • file-uploads
  • aws

Every upload form is a little door into your server. The user picks the file, the user picks the name, and the browser tells you what type it is because the user's machine said so.

That's three pieces of user input pretending to be metadata.

Most upload bugs aren't exotic. They come from trusting one of those three things a little too much.

The extension is a suggestion

invoice.pdf is a name. It is not a PDF. It could be a PHP script, an HTML page with a script tag in it, or a 400 MB video somebody renamed to get past your form.

The Content-Type header on the upload is no better. The client sends it, so the client can lie about it.

Check the contents instead. In Laravel, the mimes rule (which File::types() uses under the hood) reads the file and guesses the type from what's actually in it, not from the name:

use Illuminate\Validation\Rules\File;
 
$request->validate([
    'document' => [
        'required',
        File::types(['pdf', 'png', 'jpg'])->max('10mb'),
    ],
]);

In Node, the file-type package does the same job by reading the magic bytes at the start of the file:

import { fileTypeFromBuffer } from "file-type";
 
const allowed = new Set(["application/pdf", "image/png", "image/jpeg"]);
 
export async function assertAllowedType(buf: Buffer): Promise<string> {
  const detected = await fileTypeFromBuffer(buf);
  if (!detected || !allowed.has(detected.mime)) {
    throw new Error("Unsupported file type");
  }
  return detected.mime;
}

Use an allowlist. A blocklist of "dangerous" extensions is a list of the attacks you already thought of.

Rename everything

Never write a file to disk using the name the user gave you. That name can contain ../, spaces, unicode tricks, or just collide with somebody else's resume.pdf.

Laravel already does the right thing if you let it:

$path = $request->file('document')->store('documents', 's3');

store() uses hashName(), which generates a random filename with an extension based on the detected type. If you want to show the original name later, save getClientOriginalName() in a database column as display text. It's a label, not a path.

Same rule in TypeScript. Generate the key yourself:

import { randomUUID } from "node:crypto";
 
const key = `documents/${accountId}/${randomUUID()}`;

Keep it out of the webroot

This is the old one, and it still shows up. If uploads land in a folder your web server executes PHP from, an uploaded .php file is now a page on your site. Anyone who's cleaned up an old WordPress install has probably found something living in an uploads folder that had no business being there.

Store uploads somewhere that can't execute: a private disk, a bucket, or at minimum a directory with script execution turned off. Then serve them through your app, where you can check who's asking.

public function show(Document $document)
{
    Gate::authorize('view', $document);
 
    return redirect(
        Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(5))
    );
}

A short-lived signed URL means the link someone pastes in Slack stops working before lunch. A public bucket URL works forever, for everyone.

Serve it like you don't trust it either

Even a file that passed validation can be a problem when a browser opens it. SVGs can carry JavaScript. HTML files render as pages. That's why recent Laravel versions leave SVG out of the image rule unless you opt in.

When you serve user files from your own domain, send headers that tell the browser not to get creative:

res.setHeader("Content-Disposition", 'attachment; filename="download"');
res.setHeader("X-Content-Type-Options", "nosniff");

attachment downloads instead of rendering. nosniff stops the browser from deciding a file is HTML because it kind of looks like HTML. Better yet, serve user content from a separate domain or straight from the bucket so a bad file can't run with your app's cookies.

Images: re-encode them

If you accept profile photos, decoding and re-encoding the image throws away whatever was hiding in it. It also drops metadata, which matters more than people think. Phone photos often carry GPS coordinates.

With sharp in Node, metadata is stripped unless you ask to keep it:

import sharp from "sharp";
 
const clean = await sharp(buf).rotate().jpeg({ quality: 85 }).toBuffer();

rotate() with no arguments applies the EXIF orientation before the metadata goes away, so nobody's photo ends up sideways.

Size limits live in more than one place

Your validation rule isn't the only gate, and it's usually the last one. A request has to get past nginx's client_max_body_size and PHP's upload_max_filesize and post_max_size before Laravel ever sees it.

When those disagree, you get confusing failures. A file that's too big for PHP doesn't fail validation nicely. It just shows up as missing, and your user gets "the document field is required" for a file they definitely attached.

Pick a limit and set it everywhere. In Express with multer, put it on the parser so you stop reading the stream early:

const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 } });

Direct-to-S3 uploads still need a bouncer

Presigned uploads are great. The file goes straight to the bucket and your server never holds a 200 MB request open.

But now your server never sees the file, so none of your validation ran. Lock down what the browser is allowed to send. A presigned POST lets you put conditions on size and type:

import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
 
const { url, fields } = await createPresignedPost(s3, {
  Bucket: process.env.UPLOAD_BUCKET!,
  Key: key,
  Conditions: [
    ["content-length-range", 1, 10 * 1024 * 1024],
    ["eq", "$Content-Type", "application/pdf"],
  ],
  Fields: { "Content-Type": "application/pdf" },
  Expires: 300,
});

That Content-Type is still a label the client sets. So when the browser says "done," don't mark the upload complete on its word. Read the first chunk of the object, check the real type, and only then attach it to the record. Anything that fails gets deleted.

The short version

  • Check the contents, not the name or the header.
  • Allowlist types. Don't blocklist them.
  • Generate your own filenames. Store the original as text.
  • Keep uploads somewhere that can't execute code.
  • Serve them through authorization, with short-lived URLs and safe headers.
  • Set one size limit and enforce it at every layer.
  • Presigned uploads move the file. They don't move the responsibility.

An upload is just user input that happens to be a few megabytes. Treat it like the rest of your user input and most of these bugs never get a chance.

← All posts