08 · File Uploads & Validation¶
Letting visitors upload files — profile pictures, documents, CSVs — is one
of the riskiest things a web app does: the file's name, declared type, and
even its extension are all things an attacker fully controls. This module
covers the $_FILES superglobal and, more importantly, how to validate an
upload so a malicious file can't be smuggled onto your server as something
it isn't.
The upload form¶
<!-- The form MUST specify enctype="multipart/form-data" -- without it,
the file's binary content is never actually sent to the server. -->
<form method="post" action="upload.php" enctype="multipart/form-data">
<input type="file" name="avatar">
<button type="submit">Upload</button>
</form>
The $_FILES superglobal¶
<?php
// After the form above is submitted, $_FILES looks like this:
print_r($_FILES);
// Array
// (
// [avatar] => Array
// (
// [name] => photo.jpg // original filename -- NEVER trust this
// [type] => image/jpeg // client-reported MIME type -- NEVER trust this either
// [tmp_name] => /tmp/phpXXXXXX // where PHP actually stored the upload
// [error] => 0 // 0 means UPLOAD_ERR_OK
// [size] => 204800 // bytes
// )
// )
Both name and type come straight from the browser's request — a
malicious client can set type to image/jpeg while uploading a PHP
script, or name a file ../../etc/passwd. Neither field is safe to trust
without independent verification, which the rest of this module covers.
Step 1: check the upload error code¶
<?php
function uploadErrorMessage(int $code): ?string
{
return match ($code) {
UPLOAD_ERR_OK => null, // no error
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => "File is too large",
UPLOAD_ERR_PARTIAL => "File was only partially uploaded",
UPLOAD_ERR_NO_FILE => "No file was uploaded",
UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => "Server storage error",
default => "Unknown upload error",
};
}
$error = $_FILES["avatar"]["error"] ?? UPLOAD_ERR_NO_FILE;
if (($message = uploadErrorMessage($error)) !== null) {
exit("Upload failed: $message");
}
Checking error first matters because on any failure, tmp_name and size
may be missing, zero, or meaningless — validating file content before
checking error risks working with garbage data from a failed upload.
Step 2: validate the actual size¶
<?php
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024; // 2 MB
$size = $_FILES["avatar"]["size"];
if ($size > MAX_UPLOAD_BYTES) {
exit("File exceeds the 2 MB limit.");
}
This is a second check even though php.ini's upload_max_filesize also
caps upload size — the .ini limit is a blunt, server-wide default, while
your own check enforces the limit that's actually appropriate for this
form field (an avatar and a document import shouldn't share one ceiling).
Step 3: validate the real file content, not the client's claims¶
Never trust $_FILES[...]["type"] or the file's extension — both are just
text the client sent. Instead, inspect the file's actual bytes with the
fileinfo extension, which recognizes real file signatures ("magic bytes")
regardless of what the upload claims to be.
<?php
function detectRealMimeType(string $tmpPath): string
{
// The object-oriented finfo API needs no explicit close -- the
// resource is released automatically when $finfo goes out of scope.
$finfo = new finfo(FILEINFO_MIME_TYPE);
return $finfo->file($tmpPath);
}
$allowedTypes = ["image/jpeg", "image/png", "image/webp"];
$realType = detectRealMimeType($_FILES["avatar"]["tmp_name"]);
if (!in_array($realType, $allowedTypes, true)) {
exit("Unsupported file type: $realType");
}
A file renamed from payload.php to photo.jpg still contains PHP source
code as its actual bytes — finfo::file() reports its true content type
(e.g. text/x-php or text/plain), catching the disguise that checking the
extension alone would miss entirely.
Step 4: generate a safe filename yourself¶
Never reuse the client-supplied filename for the stored file — it can
contain path traversal sequences (../../), null bytes, or characters your
filesystem handles unexpectedly. Generate your own name and keep only a
known-safe extension.
<?php
function safeExtensionFor(string $mimeType): string
{
return match ($mimeType) {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
default => throw new InvalidArgumentException("Unsupported type"),
};
}
$extension = safeExtensionFor($realType);
$safeName = bin2hex(random_bytes(16)) . "." . $extension; // e.g. 5f3a...c9.jpg
$destination = __DIR__ . "/uploads/" . $safeName;
// move_uploaded_file() (not rename() or copy()) verifies the source path
// really was created by PHP's own upload mechanism -- rejecting attempts
// to trick your script into "uploading" an arbitrary server-side file.
if (!move_uploaded_file($_FILES["avatar"]["tmp_name"], $destination)) {
exit("Failed to save uploaded file.");
}
echo "Saved as: $safeName\n";
The random filename also sidesteps a subtler bug: two visitors uploading
files both named photo.jpg at the same moment would otherwise silently
overwrite each other.
Where NOT to store uploads¶
Store uploaded files outside the web root, or in a directory the web
server is configured not to execute scripts from. If uploads/ is
publicly reachable at https://example.com/uploads/ and your validation
has any gap, an attacker who slips a .php file past it can have the
server execute it directly just by requesting its URL — turning a file
upload bug into full remote code execution. Serving uploaded files back
through a PHP script that streams the file's bytes (checking permissions on
the way) avoids this even if a bad file ends up stored.
File upload validation cheat sheet¶
| Check | Why |
|---|---|
$_FILES[...]["error"] === UPLOAD_ERR_OK |
Confirms the upload actually completed before touching other fields |
| Your own size check | Enforces a per-form limit, independent of php.ini defaults |
finfo::file() on the real bytes |
The client's declared type/extension can be anything |
| Generate your own filename | Avoids path traversal, null bytes, and overwrite collisions |
move_uploaded_file(), never rename() |
Verifies the source was a genuine PHP upload |
| Store outside the web root | A validation gap can't become remote code execution |
How It Actually Works¶
An uploaded file never touches your PHP script directly during transfer — the SAPI layer (built into PHP's request-handling C code, before any of your opcodes run) parses the multipart/form-data request body, streams each file part to a temporary file on disk (usually under the system temp directory), and only then populates $_FILES with metadata pointing at that temp path plus the client-supplied original name, MIME type, and an error code. This is exactly why you must check $_FILES[...]['error'] first: that field reflects what the SAPI layer observed during the upload itself (partial upload, exceeded upload_max_filesize, etc.) — a check no user-level validation of the file's content can substitute for, because a failed upload might leave you with an empty or truncated temp file that "validates" as reasonable by size alone. The client-supplied MIME type and filename are pure request metadata — attacker-controlled strings echoed back from the multipart headers with no verification behind them — which is why real content-type checking calls finfo_file() (or mime_content_type()), a function that opens the temp file and inspects its actual leading bytes (magic numbers) against known file-format signatures, entirely independent of what the browser claimed. move_uploaded_file() (rather than a plain rename() or copy()) additionally verifies the source path was genuinely created by PHP's own upload machinery in this request, closing off a path-traversal trick where a value crafted to look like an upload path points somewhere else on disk.
Exercise¶
Write a function handleAvatarUpload(array $file): string (accepting one
$_FILES["avatar"]-shaped array) that runs all four validation steps above
in order — error code, size limit, real MIME type via finfo, then a
generated safe filename — returning the new filename on success or throwing
a descriptive InvalidArgumentException at the first failed check. Test it
by hand-building a few different $file arrays: one with UPLOAD_ERR_OK
pointing at a real small image, one exceeding your size limit, and one
pointing at a plain .txt file renamed with a .jpg extension.