File Uploads and Images
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5 (8.5.11), checked on 2026-10-05. Every output was captured from the Bookshelf
Lite cover upload running under php -S (with GD built from the 8.5.11 sources) and, for downloads, under PHP-FPM
behind Nginx 1.31. In Laravel, the same steps are $request->file(), validation rules such as image and
mimes, and the Storage facade (see File Storage and Images).
The upload settings
A browser uploads files in a multipart/form-data POST (<form method="post" enctype="multipart/form-data">). PHP
parses the body before the script starts, writes each file to a temporary file and describes it in $_FILES. Four
php.ini directives limit what it accepts:
| Directive | Default | Meaning |
|---|---|---|
|
|
Uploads are enabled at all |
|
|
The largest single file; a larger one gets |
|
|
The largest request body, all files and fields together; must be larger than |
|
|
The most files per request; extra files are silently dropped |
The web server has its own limit, which must be at least as large (Nginx’s client_max_body_size, 1m by default,
answers 413 Request Entity Too Large before PHP sees anything), and upload_tmp_dir (default: the system temporary
directory) must be writable and large enough. Since all files of a request are on disk before the script runs, a
script cannot reject a file early; the server limits are the only guard against huge bodies.
What arrives in $_FILES
For a field cover, $_FILES['cover'] describes one file. The output below is a real dump:
array (
'cover' =>
array (
'name' => 'cover.png', // the client's file name: untrusted
'full_path' => 'cover.png', // the relative path for directory uploads (8.1): untrusted
'type' => 'image/png', // the client's claim: untrusted
'tmp_name' => '/tmp/phpkmoqof3cgo0l30Xbtqy',
'error' => 0, // UPLOAD_ERR_OK
'size' => 5512,
),
)
For a field named covers[] with several files, the structure is transposed: each key holds an array indexed by
file, $_FILES['covers']['name'][0], $_FILES['covers']['tmp_name'][0] and so on:
array (
'covers' =>
array (
'name' => array (0 => 'cover.png', 1 => 'evil.jpg'),
'full_path' => array (0 => 'cover.png', 1 => 'evil.jpg'),
'type' => array (0 => 'image/png', 1 => 'image/jpeg'),
'tmp_name' => array (0 => '/tmp/phpbqg2pqeap095fOG2Kmv', 1 => '/tmp/php4rr7t2lic8lr2Rr8omU'),
'error' => array (0 => 0, 1 => 0),
'size' => array (0 => 5512, 1 => 28),
),
)
Note evil.jpg with type image/jpeg: curl derived the type from the extension, and the file is in fact 28 bytes of
PHP code. name, full_path and type come from the client and prove nothing.
Handling the upload
The Bookshelf Lite cover upload checks the error code, confirms the file really is an upload, detects the type from the content, generates a new name, and moves the file out of the temporary directory into storage that the web server does not serve:
<?php
// src/CoverUpload.php
declare(strict_types=1);
final class CoverUpload
{
private const array TYPES = ['image/png' => 'png', 'image/gif' => 'gif', 'image/jpeg' => 'jpg'];
private const int MAX_BYTES = 2 * 1024 * 1024;
public function __construct(private readonly string $directory) {}
/** @param array{error:int,tmp_name:string,size:int,name:string} $file */
public function store(array $file): string
{
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new RuntimeException(match ($file['error']) {
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is too large.',
UPLOAD_ERR_PARTIAL => 'The upload was interrupted.',
UPLOAD_ERR_NO_FILE => 'Choose a file.',
default => 'The upload failed.', // NO_TMP_DIR, CANT_WRITE, EXTENSION
});
}
if (!is_uploaded_file($file['tmp_name']) || $file['size'] > self::MAX_BYTES) {
throw new RuntimeException('Invalid upload.');
}
// Trust the bytes, not the client: the name and type fields are attacker-controlled
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);
$extension = self::TYPES[$mime] ?? throw new RuntimeException("Not an allowed image ($mime).");
// A generated name: no path traversal, no overwrites, no executable extensions
$name = bin2hex(random_bytes(16)) . '.' . $extension;
if (!move_uploaded_file($file['tmp_name'], "{$this->directory}/$name")) {
throw new RuntimeException('Could not save the file.');
}
return $name;
}
}
<?php
// public/upload.php
declare(strict_types=1);
require __DIR__ . '/../src/CoverUpload.php';
header('Content-Type: text/plain');
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > ini_parse_quantity(ini_get('post_max_size'))) {
http_response_code(413); // PHP discarded the whole body: $_POST and $_FILES are empty
echo "Request too large\n";
return;
}
try {
$name = (new CoverUpload(__DIR__ . '/../storage/covers'))->store($_FILES['cover'] ?? ['error' => UPLOAD_ERR_NO_FILE]);
echo "stored as $name\n";
} catch (RuntimeException $e) {
http_response_code(422);
echo $e->getMessage(), "\n";
}
$ curl localhost:8000/upload.php -F 'cover=@cover.png'
stored as 4441f408dd8f550b5d71760fc6001681.png
$ curl localhost:8000/upload.php -F 'cover=@evil.jpg;type=image/jpeg' # PHP code named .jpg
Not an allowed image (text/x-php).
$ curl localhost:8000/upload.php -F 'cover=@big.bin;filename=big.png' # 1.5 MB, upload_max_filesize=1M
The file is too large.
$ curl -X POST localhost:8000/upload.php -d x=1
Choose a file.
$ curl -w 'status %{http_code}\n' localhost:8000/upload.php -F 'cover=@big.bin' # with post_max_size=1M
Request too large
status 413
Source: PHP Manual — Error messages explained, move_uploaded_file, is_uploaded_file, finfo and ini_parse_quantity
Each rule closes a specific attack:
-
Content detection with
finfo(thefileinfoextension, which reads the file’s magic bytes) rejects a PHP script with an image name, as the second request shows. It is not a complete guarantee: a file can be a valid image and contain a payload, which re-encoding (below) removes. -
A generated filename (
bin2hex(random_bytes(16))plus an extension chosen by the server) prevents path traversal (../../public/shell.php), overwriting another user’s file, and executable or double extensions (cover.php.jpgon a misconfigured server). -
Storage outside the document root means that even a file that slips through can never be requested and executed as
https://…/uploads/shell.php. If uploads must be public, serve them from a directory where PHP execution is disabled, or better from a separate domain or object storage. -
is_uploaded_file()andmove_uploaded_file()refuse to touch anything that is not a file PHP received in this request, so a forgedtmp_namesuch as/etc/passwdis rejected.
When the whole body exceeds post_max_size, PHP throws it away before the script runs: $_POST and $_FILES are
empty, and the form looks as if it had been submitted with nothing. Compare CONTENT_LENGTH with
ini_parse_quantity(ini_get('post_max_size')) (8.2) to tell the user what happened. PHP also emits a startup warning,
POST Content-Length of 1500197 bytes exceeds the limit of 1048576 bytes; with display_errors on, that warning is
printed before the script runs, which sends the headers early, so http_response_code(413) fails and the client
receives 200. With display_errors=0 (production), the warning goes to the log and the status is 413 as above.
Serving downloads
Files stored outside the document root are delivered by a script, which is also where access control belongs. Send a
correct Content-Type, Content-Disposition: attachment for files that must be downloaded rather than displayed,
and X-Content-Type-Options: nosniff so browsers do not guess a more dangerous type. readfile() streams the file
through PHP; for large files, let the web server do it with X-Accel-Redirect (Nginx) or X-Sendfile (Apache with
mod_xsendfile), which frees the PHP worker immediately:
<?php
// public/download.php?file=kindred.png
declare(strict_types=1);
$name = basename($_GET['file'] ?? ''); // never a path from the client
$path = __DIR__ . "/../storage/covers/$name";
if (!preg_match('/^[a-z0-9-]+\.png$/', $name) || !is_file($path)) {
http_response_code(404);
return;
}
// ... check that the current user may see this file ...
header('Content-Type: image/png');
header('Content-Disposition: attachment; filename="' . $name . '"');
header('X-Content-Type-Options: nosniff');
if (($_GET['via'] ?? '') === 'php') {
header('Content-Length: ' . filesize($path));
readfile($path); // PHP streams the file
} else {
header('X-Accel-Redirect: /protected-covers/' . $name); // Nginx streams it, the worker is free
}
location /protected-covers/ {
internal; # only reachable through X-Accel-Redirect
alias /app/storage/covers/;
}
$ curl -D - -o kindred.png 'http://bookshelf.test/download.php?file=kindred.png'
HTTP/1.1 200 OK
Content-Type: image/png
Content-Length: 5512
Content-Disposition: attachment; filename="kindred.png"
$ curl -o /dev/null -w '%{http_code}\n' http://bookshelf.test/protected-covers/kindred.png
404
$ curl -o /dev/null -w '%{http_code}\n' 'http://bookshelf.test/download.php?file=../../etc/passwd'
404
Source: readfile and Nginx — internal
Nginx removed the X-Accel-Redirect header and served the file itself with its own Content-Length; requesting the
internal location directly, or a path with ../, returns 404. If a file name comes from users, encode it in
Content-Disposition with the RFC 6266 filename*=UTF-8''… form and strip quotes and line breaks.
Images: resizing and re-encoding with GD
PHP has two image libraries: GD (the gd extension, bundled with PHP) and Imagick (a PECL extension wrapping
ImageMagick, more formats and better resampling). For covers, two operations matter: making thumbnails, and
re-encoding every upload, which writes a fresh image from the decoded pixels and so drops metadata, comments and
anything appended to the file.
<?php
declare(strict_types=1);
/** Re-encode an uploaded image and write a thumbnail of the given width. */
function makeThumbnail(string $source, string $target, int $width = 300): array
{
[$w, $h, $type] = getimagesize($source) ?: throw new RuntimeException('Not an image.');
if ($w * $h > 40_000_000) {
throw new RuntimeException('Image too large to process.'); // decompression-bomb guard
}
$image = imagecreatefromstring(file_get_contents($source)) ?: throw new RuntimeException('Unreadable image.');
$thumb = imagescale($image, $width); // keeps the aspect ratio
imagepng($thumb, $target, 9); // re-encoding drops anything hidden in the file
return [$w, $h, image_type_to_mime_type($type), imagesx($thumb), imagesy($thumb)];
}
var_dump(makeThumbnail(__DIR__ . '/../storage/cover.png', __DIR__ . '/../storage/covers/thumb.png'));
// array(5) { [0]=> int(800) [1]=> int(1200) [2]=> string(9) "image/png" [3]=> int(300) [4]=> int(450) }
Source: PHP Manual — GD and Image Functions, imagescale, getimagesize and Imagick
Check the dimensions with getimagesize() before decoding: a small file can declare an enormous canvas (a
decompression bomb) that needs gigabytes of memory to decode, since GD uses about 4 bytes per pixel for a true-colour
image. Since PHP 8.0, images are GdImage objects freed automatically; imagedestroy() does nothing and is
deprecated since 8.5. Which formats GD supports depends on how it was built (gd_info() lists them): the build used
here had PNG, GIF and BMP support, while the distribution packages usually add JPEG, WebP and AVIF.
Where older books and tutorials are outdated
-
Trusting
$_FILES['…']['type']and the original name, and saving uploads under the web root (Nixon ch. 7, Powers ch. 9, Kromann ch. 15) is the classic route to remote code execution: detect the type withfinfo, generate the name, and store outside the document root. Powers'Uploadclass already checks MIME types and renames files; the type check must use the content, not the browser’s claim. -
Echoing the uploaded file name unescaped (the same chapters) is an XSS hole; escape it like any other input.
-
imagedestroy()to free memory, as the GD thumbnail code written for PHP 7 does (Powers, and the image resizing in Nixon’s capstone), has done nothing since PHP 8.0 and is deprecated since 8.5; GD images are objects. -
$_FILESwithoutfull_path: thefull_pathkey (8.1) carries the relative path of files uploaded from a directory picker; it is as untrusted asname.
References
-
PHP Manual — Handling file uploads: POST method uploads, error messages, common pitfalls, multiple files
-
move_uploaded_file, is_uploaded_file, finfo, ini_parse_quantity
-
PHP Manual — Core php.ini directives (
file_uploads,upload_max_filesize,post_max_size,max_file_uploads) -
Robin Nixon — Learning PHP, MySQL & JavaScript, 5th ed. (O’Reilly, 2018), David Powers — PHP 7 Solutions, 4th ed. (Apress, 2019) and Frank M. Kromann — Beginning PHP and MySQL, 5th ed. (Apress, 2018)