Uploading files in PHP is achieved with the move_uploaded_file() function.
The HTML form for uploading single or multiple files must include the
enctype="multipart/form-data"
attribute. Use the POST method:
<form method="post" enctype="multipart/form-data" action="upload.php">
File: <input type="file" name="pictures[]" multiple="true">
<input type="submit">
</form>
And the PHP upload.php
script looks like the following:
<?php
foreach ($_FILES['pictures']['error'] as $key => $error) {
if ($error == UPLOAD_ERR_OK) {
$tmpName = $_FILES['pictures']['tmp_name'][$key];
// basename() may prevent directory traversal attacks, but further
// validations are required
$name = basename($_FILES['pictures']['name'][$key]);
move_uploaded_file($tmpName, "/var/www/project/uploads/$name");
}
}
Don’t stop here just yet and continue reading! The uploaded files must be
validated for security purposes. A lot of hacks can occur when uploading hasn’t
been properly secured. Imagine a malicious attacker uploads evil.php
which is
publicly accessible over https://example.com/uploads/evil.php
!
Always make sure that you implement server-side validation in order to be able to upload securely, and make sure that you understand the reasons for this, and the security vulnerabilities that you would otherwise be exposed to.
To avoid directory traversal (a.k.a. path traversal) attacks, use basename()
like shown above, or even better, rename the file completely like in the next
step.
Renaming uploaded files avoids duplicate names in your upload destination, and
also helps to prevent directory traversal attacks. If you need to keep the
original filename, you can it in a database for retrieval in the future. As an
example, renaming a file with microtime()
and some random number:
$uploadedName = $_FILES['upload']['name'];
$ext = strtolower(substr($uploadedName, strripos($uploadedName, '.')+1));
$filename = round(microtime(true)).mt_rand().'.'.$ext;
You can also use hashing functions like hash_file()
and sha1_file()
to
build filenames. This method can save some storage space when different users
upload the same file.
$uploadedName = $_FILES['upload']['name'];
$ext = strtolower(substr($uploadedName, strripos($uploadedName, '.')+1));
$filename = hash_file('sha256', $uploadedName) . '.' . $ext;
Instead of relying on file extensions, you can get the mime-type of a file with finfo_file():
$finfo = finfo_open(FILEINFO_MIME_TYPE); // return mime-type extension
echo finfo_file($finfo, $filename);
finfo_close($finfo);
For images, a check that’s more reliable, but still not really good enough is
using the getimagesize()
function:
$size = @getimagesize($filename);
if (empty($size) || ($size[0] === 0) || ($size[1] === 0)) {
throw new \Exception('Image size is not set.');
}
Checking for uploaded file size is important to not overload the server with too big file(s). When checking uploaded file size there are several main levels to look into.
upload_max_filesize
ini directiveThe most important is to limit the upload_max_filesize
and post_max_size
ini
directives in the php.ini file. This prevents the server disk size from being
overloaded on the server. It stops uploading as soon as the upload_max_filesize
is reached and sets the UPLOAD_ERR_INI_SIZE
error code to the
$_FILES['key']['error']
. If the post_max_size
has been reached the $_POST
and $_FILES
will be empty.
Second most important beside above is to also check the uploaded file size in the
application code using either filesize($_FILES['key']['tmp_name])
function or
the $_FILES['files']['size']
. Both are equally valid when uploading files is
concerned.
To limit or check the size of the uploaded file from the $_FILES['key']['size']
:
if ($_FILES['pictures']['size'] > 1000000) {
throw new Exception('Exceeded file size limit.');
}
To check the uploaded file size on the client side is optional and not safe to rely on, yet it improves the user experience.
Then there is also additional PHP specific optional check of using a special
hidden field with name MAX_FILE_SIZE
(or max_file_size
it is case insesitive)
in the HTML form that PHP can use. However, it can be spoofed the same way as
the client side validation by the evil client side so it is not reliable. It is
more of an user experience improvement in cases where very large files are uploaded
for example videos.
For example, the following form will limit the file size to 1MB or 1048576 bytes (1*1024*1024):
<form method="post" enctype="multipart/form-data" action="upload.php">
<input type="hidden" name="max_file_size" value="2097152">
File: <input type="file" name="pictures[]" multiple="true">
<input type="submit">
</form>
The max_file_size
hidden field needs to be added before the file input field
to be effective on the PHP side.
It limits the upload process in the following way:
1.) PHP first checks if the current uploaded bytes is bigger than the
upload_max_filesize
ini directive.
2.) If not, it additionally checks if the max_file_size
field has been defined
and if the current uploaded bytes are bigger than it. If it is it interrupts the
upload process and sets the UPLOAD_ERR_FORM_SIZE
error code in the
$_FILES['key']['error']
. This way user don’t need to wait for the 100% of the
file is uploaded but only the max_file_size
field value bytes are uploaded and
application stops the upload process.
Instead of saving uploaded files to a public location available at
https://example.com/uploads
, storing them in a publicly inaccessible folder
is a good practice. To deliver these files, so called proxy scripts are used.
For better user experience, HTML offers the accept attribute to limit filetypes by the extension or mime-type in the HTML, so users can see the validation errors on the fly and select only allowed filetypes in their browser. However, browser support is limited at the time of writing this. Keep in mind that client-side validation can be easily bypassed by hackers. The server-side validation steps explained above are more important forms of validation to use.
Let’s take all of the above into consideration and look at a very simple example:
// Check if we've uploaded a file
if (!empty($_FILES['upload']) && $_FILES['upload']['error'] == UPLOAD_ERR_OK) {
// Be sure we're dealing with an upload
if (is_uploaded_file($_FILES['upload']['tmp_name']) === false) {
throw new \Exception('Error on upload: Invalid file definition');
}
// Rename the uploaded file
$uploadName = $_FILES['upload']['name'];
$ext = strtolower(substr($uploadName, strripos($uploadName, '.')+1));
$filename = round(microtime(true)).mt_rand().'.'.$ext;
move_uploaded_file($_FILES['upload']['tmp_name'], __DIR__.'../uploads/'.$filename);
// Insert it into our tracking along with the original name
}
The server-side validation mentioned above can be still bypassed by embedding custom code inside the image itself with tools like jhead, and the file might be ran and interpreted as PHP.
That’s why enforcing filetypes should also be done at the server level.
Make sure Apache is not configured to interpret multiple files as the same (e.g., images being interpreted as PHP files). Use the ForceType directive to force the type on the uploaded files.
<FilesMatch "\.(?i:pdf)$">
ForceType application/octet-stream
Header set Content-Disposition attachment
</FilesMatch>
Or in the case of images:
ForceType application/octet-stream
<FilesMatch "(?i).jpe?g$">
ForceType image/jpeg
</FilesMatch>
<FilesMatch "(?i).gif$">
ForceType image/gif
</FilesMatch>
<FilesMatch "(?i).png$">
ForceType image/png
</FilesMatch>
On Nginx, you can use the rewrite rules, or use the mime.types
configuration
file provided by default.
location ~* (.*\.pdf) {
types { application/octet-stream .pdf; }
default_type application/octet-stream;
}