DEV3 Technologies home

Safe resume upload validation

A form request that checks extension, size and real file signature, then stores resumes on a private disk under a random name.

LaravelUploadsuploadvalidationform-requestsecurity

Code

<?php

namespace App\Http\Requests;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;

class ResumeUploadRequest extends FormRequest
{
    /** Allowed extension => leading bytes every genuine file of that type starts with. */
    private const SIGNATURES = [
        'pdf' => '%PDF-',
        'docx' => "PK\x03\x04",
    ];

    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'resume' => ['required', 'file', 'max:5120', 'extensions:pdf,docx'],
        ];
    }

    public function after(): array
    {
        return [function (Validator $validator) {
            $file = $this->file('resume');
            if (! $file instanceof UploadedFile || $validator->errors()->has('resume')) {
                return;
            }
            if (! $this->hasValidSignature($file)) {
                $validator->errors()->add('resume', 'This file does not look like a real PDF or DOCX.');
            }
        }];
    }

    private function hasValidSignature(UploadedFile $file): bool
    {
        $expected = self::SIGNATURES[strtolower($file->getClientOriginalExtension())] ?? null;
        $head = file_get_contents($file->getRealPath(), false, null, 0, 8);

        return $expected !== null && is_string($head) && str_starts_with($head, $expected);
    }

    /** Store under a random name on a non-public disk; keep the original name only as data. */
    public function storeResume(): array
    {
        $file = $this->file('resume');
        $ext = strtolower($file->getClientOriginalExtension());
        $path = $file->storeAs('resumes/'.now()->format('Y/m'), bin2hex(random_bytes(16)).'.'.$ext, 'private');

        return [
            'path' => $path,
            'original_name' => mb_substr(basename($file->getClientOriginalName()), 0, 120),
            'size' => $file->getSize(),
        ];
    }
}

How it works

What it solves

A public job application form accepts files from strangers. Relying on the extension or the browser-reported MIME type is not enough, because both are chosen by the sender. This request class validates the file in layers and stores it so it can never be executed or guessed.

How it works

rules() handles the cheap checks first: a required file, at most 5 MB and an extension of pdf or docx. The after() hook runs only when those pass and then reads the first bytes of the real temporary file. A PDF must start with %PDF- and a DOCX, which is a zip container, with the PK header. A script renamed to cv.pdf fails here.

storeResume() saves the upload on a disk named private, in a year and month folder, under a random 32 character hex name with the validated extension. The visitor's file name is trimmed and returned only as metadata to display later, never used as a path. Because the disk is not served by the web server, downloads must go through a controller that checks permissions.

Adapting it

  • Add doc support by adding the OLE header bytes to SIGNATURES.
  • Scan the stored path with your antivirus tool from a queued job and quarantine on a hit.
  • Raise the size limit through config rather than editing the rule.

Pitfalls

  • Define a private disk in config/filesystems.php pointing outside public/.
  • A matching signature does not prove the file is harmless; PDFs can contain scripts, so serve downloads with Content-Disposition: attachment.
  • Keep PHP upload_max_filesize at or above the rule.

How to use it

  1. Add a private disk to config/filesystems.php with root storage_path('app/private') and no public URL.
  2. Put the class in app/Http/Requests/.
  3. Type-hint it in your controller: public function store(ResumeUploadRequest $request).
  4. Persist the metadata: Application::create([...$request->validated(), ...$request->storeResume()]) (adjust to your columns).
  5. Serve downloads through an authorized route using Storage::disk('private')->download($path).

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets