File Upload Security: MIME Sniffing, Storage Isolation, and Decompression Bombs

Go beyond the mechanics of handling $_FILES and build a hardened upload pipeline: verify actual file content instead of trusting client-supplied MIME types or extensions, store uploads outside the public webroot and serve them through an access-checked controller, guarantee uploaded files can never be executed as code, and defend against decompression/image-bomb attacks where a tiny file expands into gigabytes of memory or disk.

An earlier lesson in this course covered the mechanics of file uploads -- move_uploaded_file(), $_FILES, form encoding. This lesson assumes you already know that and asks a harder question: given that any user on the internet can send your server arbitrary bytes labeled as a 'file', what stops those bytes from becoming a web shell, a denial-of-service bomb, or a way to read files that belong to other users? File upload endpoints are one of the highest-value targets in any web application precisely because they are a sanctioned way to get attacker-controlled content onto your server's disk -- every other injection vulnerability requires finding a bug, but a file upload feature hands the attacker a byte-perfect delivery channel by design. The job of upload security is to make sure that delivery channel can never become an execution channel.

Never Trust the Client's Content-Type -- Sniff the Real Content

Every uploaded file arrives in $_FILES with a 'type' key -- but that value is read directly from the multipart/form-data request's Content-Type header, which the BROWSER sets based on the file's extension or the JavaScript that built the request. It is exactly as trustworthy as a query string parameter, because it is one: an attacker using curl or a modified form can set it to anything at all, including 'image/png' on a file that is actually a PHP script. Relying on $_FILES['photo']['type'] to decide whether an upload is really an image is not a security check, it just documents what the attacker claimed.

The extension is barely better. A filename ending in .jpg tells you nothing about the bytes inside the file -- it is just a string the uploader chose, and PHP will happily accept 'shell.php.jpg' or a file with no relationship at all between its name and its content. The only trustworthy signal is the file's actual byte content: its magic-number header (the first few bytes every real file format begins with, like PNG's 89 50 4E 47 or a JPEG's FF D8 FF) and, for image formats specifically, whether an image-processing library can actually decode it as a valid image of that type.

PHP's fileinfo extension (finfo) reads those magic bytes and reports the format it detects, independent of anything the client claimed. Combining a finfo check with an attempt to actually decode the file with GD or Imagick (for images) gives you real content validation: a file that both sniffs as image/png AND decodes successfully as a PNG is far more likely to actually be one than a file that merely has '.png' in its name.

• $_FILES[...]['type'] is client-supplied and attacker-controlled -- never use it as a security decision, only as a hint for the UI. • The filename extension is just a string the uploader chose -- it has no cryptographic or structural relationship to the file's actual bytes. • finfo_file()/finfo_buffer() with FILEINFO_MIME_TYPE reads the file's real magic-number signature server-side. • For images specifically, also attempt a real decode (getimagesize(), imagecreatefrompng(), or Imagick) -- a file can pass a magic-number sniff yet still be malformed or polyglot.

Storage Isolation: Never Serve Uploads Directly From the Webroot

The single most important structural decision in an upload pipeline is where the files physically live. If uploads are saved inside the document root (public_html/uploads/, public/uploads/) and the web server is configured to serve that directory statically, then every uploaded file is directly reachable by URL the instant it lands on disk -- no application code runs, no access check happens, whatever bytes were uploaded are now public. That single design choice is behind an enormous share of real upload vulnerabilities: it doesn't matter how well you validate content if the fallback path is 'just guess or brute-force the filename and fetch it directly.'

The fix is storage isolation: uploaded files are written to a directory OUTSIDE the web server's document root entirely -- a path the web server has no route to and will never serve as a static file, no matter what URL is requested. Every read of that file then goes through a PHP controller: a request like /files/report-42 is handled by application code that looks up which physical file 'report-42' maps to, checks whether the CURRENT authenticated user is allowed to see it, and only then streams the bytes back with readfile() or a similar call. This turns every file access into an authorization decision instead of a static-file lookup, which is exactly what you want for anything a user uploaded that shouldn't be world-readable by URL guessing.

This also closes off enumeration and IDOR-style access: if filenames are UUIDs or hashed IDs rather than the user's original filename, and every fetch is gated by an ownership check in the controller, an attacker who somehow learns one user's file identifier still can't retrieve a DIFFERENT user's file just by trying nearby values.

• Store uploads outside the document root -- a path like /var/app/uploads/, never public/uploads/. • Serve files through a PHP controller (e.g. GET /files/{id}) that authenticates, authorizes, then streams -- never through a direct static URL. • Generate a random storage filename (a UUID) independent of the user's original filename; keep the original name only as metadata in a database row. • Log and rate-limit file-serving endpoints the same way you would any other access-controlled resource.

Executable Prevention: An Uploaded File Must Never Run as Code

Even with storage isolation, a second, independent layer matters: an uploaded file must never be placed anywhere the web server or PHP interpreter would EXECUTE it, regardless of its content. If a misconfiguration ever does let an uploaded file be reached directly (a webroot ever gets pointed at the wrong directory, a CDN sync mistake, a future refactor), the difference between 'an attacker can view an uploaded image' and 'an attacker gets remote code execution' is entirely whether that location will execute PHP.

Concretely: never allow the web server (Apache/Nginx/PHP-FPM) to have script-handling enabled for the upload directory at all. On Apache this means no AddHandler/SetHandler for .php in that directory (or better, an explicit <Directory> block that disables script execution there); on Nginx it means the location block matching your uploads path should never forward to the PHP-FPM socket, full stop -- static files only, or no route at all if storage isolation is already in place. Defense-in-depth also strips or neutralizes double extensions and null-byte tricks: a filename like 'shell.php.jpg' or 'shell.php%00.jpg' has historically bypassed naive extension checks in various stacks, which is one more reason to generate an entirely new random filename server-side rather than preserving any part of the uploaded name.

The most robust version of this rule is architectural rather than a checklist: uploaded content is served by a runtime that CANNOT execute PHP at all -- a plain static file host, a different subdomain/origin with no PHP-FPM behind it, or object storage like S3 fronted by a CDN. If the serving surface has no interpreter attached, there is no upload content that can ever become a running script, no matter what validation gaps exist upstream.

• Never give the uploads directory script-execution permission at the web-server config level -- this is the backstop even if every other check fails. • Generate a fresh random filename with no extension taken from user input; store the original name/extension only as metadata. • Prefer serving uploads from a separate origin/subdomain or object storage with no PHP interpreter attached at all. • Treat 'double extension' and null-byte filename tricks as reasons to rebuild the filename from scratch, not reasons to write a smarter blacklist.

Decompression and Image-Bomb Concerns

Content validation and storage isolation defend against malicious CONTENT. A separate class of attack targets your server's RESOURCES using files that are entirely valid: a decompression bomb (or 'zip bomb') is a small, perfectly legitimate archive that decompresses into an enormous amount of data -- a few kilobytes on disk expanding to gigabytes or more in memory once extracted, because compression algorithms can represent highly repetitive data with an extreme ratio. If your upload pipeline ever extracts a user-supplied .zip, decompresses a gzip-encoded upload, or processes any compressed format without a size cap, an attacker can crash the process or exhaust disk/memory with a file that is, by every content check described above, completely valid.

Image files have a closely related version of the same problem, sometimes called a pixel-flood or image bomb: image formats store their declared width and height in a small header BEFORE the actual pixel data. A file can legitimately be a valid PNG or JPEG yet declare dimensions like 50000x50000 pixels -- decoding that fully into an in-memory bitmap (width * height * bytes-per-pixel) can require many gigabytes of RAM from a file that is only a few kilobytes on disk, because most of that declared canvas is trivially compressible (e.g. solid color). A naive pipeline that calls imagecreatefrompng() or an Imagick load directly on every upload, with no cap, hands an attacker a way to OOM-kill your worker process with a single small file.

The defense is the same shape in both cases: inspect the DECLARED size before doing the expensive full decode, and reject anything over a sane limit before committing to the work. For images, getimagesize() reads just the header and returns width/height cheaply without decoding pixel data -- check that against a maximum (e.g. reject anything over 8000x8000) before ever calling an actual decode function. For archives, read the central directory / declared uncompressed size from the archive's own metadata (ZipArchive exposes per-entry sizes) and refuse to extract if the declared total exceeds a hard cap, and additionally enforce a running byte-count ceiling WHILE extracting (never trust the declared size alone -- verify it as you go) so a lying header can't bypass the check either.

• A zip bomb: a tiny compressed file whose declared/actual uncompressed size is gigabytes -- extracting it blindly exhausts disk or memory. • An image bomb: a valid, small image file declaring an enormous pixel canvas (e.g. 50000x50000) that blows up RAM once fully decoded. • Check getimagesize() (cheap, header-only) BEFORE any full image decode, and reject dimensions beyond a sane maximum. • For archives, check each entry's declared uncompressed size AND enforce a running byte cap during extraction -- don't trust the declared size alone.

A Hardened Upload-Intake Function

Size and dimension checks run BEFORE any expensive decode -- cheap rejects happen first. $isolatedStorageDir must be a path with no web-server route to it at all, e.g. /var/app/private_uploads. The returned $storedName is a random token; the original filename is kept only as metadata, never as the on-disk name.

function acceptUpload(array $file, string $isolatedStorageDir): string
{
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new RuntimeException('Upload failed.');
    }
    if ($file['size'] > 5 * 1024 * 1024) { // 5 MB hard cap, checked before any decode
        throw new RuntimeException('File too large.');
    }

    // 1. Sniff the REAL content type -- never trust $file['type'].
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $realMime = $finfo->file($file['tmp_name']);
    $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
    if (!isset($allowed[$realMime])) {
        throw new RuntimeException('Unsupported file type.');
    }

    // 2. Cheap header-only dimension check BEFORE a full decode (image-bomb guard).
    $dims = getimagesize($file['tmp_name']);
    if ($dims === false || $dims[0] > 6000 || $dims[1] > 6000) {
        throw new RuntimeException('Image dimensions rejected.');
    }

    // 3. Random storage name; original name/extension never touches the filesystem.
    $storedName = bin2hex(random_bytes(16)) . '.' . $allowed[$realMime];

    // 4. Destination is OUTSIDE the webroot -- no static route can ever reach it.
    $destination = rtrim($isolate
…

Code Examples

Because the file lives outside the document root, this controller is the ONLY way to ever read it back. It re-derives the real path from a random public_id (never trusting a client-supplied filename), and explicitly checks that the requesting user owns the row before streaming any bytes -- an IDOR-style 'just change the ID' attack is blocked by that one comparison, not by obscurity.

if ((int) $row['owner_id'] !== $currentUserId) { http_response_code(403); return; }

Common mistake: Putting the ownership check AFTER reading the file into memory, or skipping it because 'the ID is a random UUID so no one will guess it' -- an unguessable ID is not an authorization check; a leaked link, a referrer header, or a shared screenshot can all expose it.

This checks the archive's DECLARED total size before extracting anything, then enforces a running byte counter DURING extraction, because a maliciously crafted archive's headers can lie. basename() on each entry name also prevents a 'zip-slip' path-traversal entry (e.g. '../../etc/cron.d/evil') from writing outside $destDir. A real production pipeline would also cap the NUMBER of entries (a bomb can also be many tiny files rather than one huge one) and run extraction in a resource-limited worker process, not the main request handler.

// GET /files/{id}  -- routed to this controller; NOT a static file route.
function serveFile(PDO $db, int $currentUserId, string $fileId): void
{
    $stmt = $db->prepare('SELECT owner_id, stored_name, original_name, mime_type
                           FROM uploads WHERE public_id = ?');
    $stmt->execute([$fileId]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($row === false) {
        http_response_code(404);
        return;
    }

    // Explicit authorization check -- being LOGGED IN is not the same as OWNING this file.
    if ((int) $row['owner_id'] !== $currentUserId) {
        http_response_code(403);
        return;
    }

    $path = '/var/app/private_uploads/' . $row['stored_name'];
    if (!is_file($path)) {
        http_response_code(404);
        return;
    }

    header('Content-Type: ' . $row['mime_type']);
    header('Content-Disposition: inline; filename="' . basename($row['original_name']) . '"');
    header('X-Content-Type-Options: nosniff'); // stop the browser from re-sniffing a served file
    readfile($path);
}
function extractArchiveSafely(string $zipPath, string $destDir, int $maxTotalBytes = 50 * 1024 * 1024): void
{
    $zip = new ZipArchive();
    if ($zip->open($zipPath) !== true) {
        throw new RuntimeException('Invalid archive.');
    }

    $declaredTotal = 0;
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $stat = $zip->statIndex($i);
        $declaredTotal += $stat['size']; // declared uncompressed size per entry
    }
    if ($declaredTotal > $maxTotalBytes) {
        $zip->close();
        throw new RuntimeException('Archive exceeds allowed uncompressed size.');
    }

    $writtenTotal = 0;
    for ($i = 0; $i < $zip->numFiles; $i++) {
        $name = $zip->getNameIndex($i);
        // Never trust an entry name blindly -- guard against zip-slip path traversal too.
        $safeName = basename($name);

        $stream = $zip->getStream($name);
        $out = fopen($destDir . '/' . $safeName, 'wb');
        while (!feof($stream)) {
            $chunk = fread($stream, 8192);
            $writtenTotal += strlen($chunk);
            if ($writtenTotal > $maxTotalBytes) { // re-verify WHILE extracting, don't trust headers alone
                fclose($out);
             
…

Upload Request Flow With Storage Isolation

The upload directory has no web-server route at all -- every read is forced through the authorization check in step 5, so there is no 'just guess the filename' shortcut.

Browser                Web Server (Nginx/Apache)         PHP App
  -------                --------------------------         -------
  POST /upload  ───────▶  routes to PHP  ───────────────▶  1. finfo sniff real content
                                                             2. getimagesize() size guard
                                                             3. random filename generated
                                                             4. write OUTSIDE webroot
                                                                /var/app/private_uploads/
                                                                        │
                                                                        │ no static route exists
  GET /files/abc123 ────▶  routes to PHP  ───────────────▶  5. auth check: is this MY file?
                                                             6. readfile() streams bytes back
                          (Nginx never serves               (any direct request to
                           private_uploads/ as static)       private_uploads/xyz.png → 404)

Important Rules

Validate the REAL content (finfo + an actual decode attempt), never the client-supplied MIME type or the filename extension.

Store uploads outside the document root; serve them only through an authenticated, authorized controller.

Never let the upload directory be script-executable at the web-server config level -- this is the backstop layer.

Generate a random server-side filename; keep the original name only as metadata, never as the on-disk name.

Check declared image dimensions (getimagesize()) and archive uncompressed sizes BEFORE a full decode/extract, and re-verify actual bytes as you go.

Why This Keeps Happening in Practice

A huge share of real-world 'arbitrary file upload leads to RCE' disclosures follow the exact same shape: a webroot-relative uploads directory, a check limited to the client-supplied extension or Content-Type, and a web server still configured to execute .php files anywhere under the document root. Content management systems, forum software, and image-hosting plugins have repeatedly shipped exactly this combination, and the fix reported each time is the same three layers covered here -- validate real content, isolate storage, and make the serving location incapable of executing code, so that even a validation gap doesn't turn into code execution.

if ($_FILES['photo']['type'] === 'image/png') { move_uploaded_file(...); }

$real = (new finfo(FILEINFO_MIME_TYPE))->file($_FILES['photo']['tmp_name']); if ($real === 'image/png') { ... }

$_FILES[...]['type'] is read from a request header the client fully controls. Only inspecting the file's actual bytes with finfo tells you what the content really is.

Saving uploads to public/uploads/{$originalFilename} and linking to it directly.

Saving uploads to a directory outside the webroot with a random name, and serving them through a controller that checks ownership before streaming.

A file inside the document root is reachable by anyone who can guess or find its URL, with zero application-level access control -- storage isolation turns every file read into an authorization decision.

imagecreatefrompng($_FILES['photo']['tmp_name']) called directly on every upload with no size check.

Call getimagesize() first, reject anything over a sane width/height cap, and only then decode fully.

A small file can declare an enormous pixel canvas; decoding it fully allocates memory proportional to width * height, which can OOM-kill the worker process from a tiny upload.

Why is $_FILES['photo']['type'] unsafe to use as a security check? A. It is deprecated in PHP 8.2 and will be removed B. It is read from a client-controlled request header, not the file's actual content C. It only works for images, never for other file types D. It requires the fileinfo extension to be disabled

B. It is read from a client-controlled request header, not the file's actual content — $_FILES[...]['type'] comes straight from the Content-Type the browser (or an attacker's script) put in the multipart request -- it can be set to anything regardless of the file's real bytes.

What is the main security benefit of storing uploaded files outside the web server's document root? A. It makes uploads process faster B. It automatically compresses the files to save disk space C. There is no static route to the files, so every read must go through an application controller that can enforce authorization D. It bypasses the need for MIME-type validation entirely

C. There is no static route to the files, so every read must go through an application controller that can enforce authorization — With no web-server route into the storage directory, there is no way to fetch a file except through the PHP controller you write -- which means every read can be gated by an explicit ownership/authorization check.

A 12 KB image file declares dimensions of 40000x40000 pixels in its header. What is the correct first defense? A. Reject it based on getimagesize() results before attempting a full pixel decode B. Allow it since the file size on disk is small C. Convert it to a different format first, then check dimensions D. There is no defense needed since PHP handles memory automatically

A. Reject it based on getimagesize() results before attempting a full pixel decode — getimagesize() reads only the header cheaply. Checking the declared width/height against a sane cap before calling a full decode function prevents an attacker from using a tiny file to force a massive memory allocation.

Why should an uploaded file's original filename/extension never be used as the on-disk storage name? A. Long filenames slow down the filesystem B. It can be used to smuggle a double extension or other trick past naive checks, and gives no security benefit over a random name C. PHP cannot write files with unicode characters in their names D. It is required by the multipart/form-data specification

B. It can be used to smuggle a double extension or other trick past naive checks, and gives no security benefit over a random name — A user-supplied name is just a string the uploader chose -- tricks like 'shell.php.jpg' or embedded path segments exploit naive extension logic. A random server-generated name removes that entire attack surface; the original name can still be kept as metadata for display.

Extend the acceptUpload() function from the syntax block so that it also enforces a maximum ARCHIVE entry count of 200 when the uploaded file is a .zip (in addition to the existing size and MIME checks), and write the reasoning for why an entry-count cap is a separate concern from a total-byte-size cap.

Add if ($zip->numFiles > 200) { throw new RuntimeException('Too many entries.'); } right after opening the archive, before summing declared sizes. A byte-size cap alone doesn't stop a 'many tiny files' bomb, because total declared bytes can be trivially small while the sheer entry count exhausts filesystem resources (inodes, open file descriptors) or simply takes an extraction worker an unreasonable amount of wall-clock time per request.

Think about what happens if an attacker uploads an archive containing 500,000 nearly-empty files instead of one huge file -- the total DECLARED size can stay small while still exhausting inodes, open-file-handle limits, or extraction time.