Caution: File upload is always a major security issue !
Note: The following applies for a standard Apache-PHP-HTML-CSS-JS website, but much of it is valid in other cases too.
If you plan to let users upload files to your site you must consider several issues:
- Security – ensure that no malicious code is uploaded and no unauthorized access to these files is allowed;
- Reliability – ensure that files are uploaded intact and into the right folder;
- User experience – ensure that your GUI is informative, friendly and easy.
To solve these issues you must take care about:
- Front-end GUI;
- Back-end code;
- Server settings.
The Standard file-upload GUI uses HTML form in the front-end page.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <form action="upload_handler.php" method="post" enctype="multipart/form-data"> <label>Choose files for upload:</label> <input type="file" name="userfile[]" multiple="multiple"><br> <label>Submit form to upload files:</label> <input type="submit" name="submit" value="Submit"> </form> </body> </html>
Note: The above example is for upload of multiple files.
How it works
After the form is submitted to the server, the PHP script in “upload_handler.php” receives a three-dimensional associative array containing the following data for the i-th uploaded file (i = 0…n-1 where n is the number of all files):
$_FILES[“userfile”][“name”][i] – the name of the file (e.g. ‘myphoto.jpg’)
$_FILES[“userfile”][“type”][i] – the file type (e.g. ‘image/jpg’)
$_FILES[“userfile”][“size”][i] – the file size in bytes (e.g. 123456)
$_FILES[“userfile”][“tmp_name”][i] – a temporary file name (e.g. ‘php30A8.tmp’)
$_FILES[“userfile”][“error”][i] – the error code of the upload (e.g. 0)
Initially, each file is uploaded to server-specific temporary directory with its temporary name supplied by the server. Then the script performs security checks on the received data and, if all checks succeed, it moves the file to its destination directory. The server erases the temporary file after the script exits.
This is a live file-upload demo. The File Upload Form is wrapped in an <iframe> to avoid reloading of the page after submitting it. However, the demo back-end does not save the uploaded files.
- The Standard GUI always works, but:
- The upper button size and caption vary across browsers;
- You have to press two buttons on the form;
- Form page is reloaded upon form submission.
The advanced file-upload GUI uses Ajax in the front-end page.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <style> .wrap { } .input-wrap { } .input-wrap input { } </style> </head> <body> <div class="wrap"> <label>Choose files for upload:</label> <span class="input-wrap"> <input type="file" name="userfile[]" multiple="multiple"> </span> </div> <script> </script> </body> </html>
This is a live file-upload demo. The File Upload Form is wrapped in an <iframe> just for convenience.
The demo back-end does not save the uploaded files.
- Compared to the Standard GUI, the Advanced GUI takes less area on your page, does not reload the page upon file upload and provides much better user experience, but uses specific styling and javascript.
Uploading of arbitrary files is a security issue, especially if upload is allowed to unauthorized users.
In order to minimize the risk of injecting malicious code, the upload script needs to be well written, clear and checked against various scenarios. Implementation of a good upload script is just a part of the game. Server settings, use of .htaccess files as well as the further processing of the uploaded files need careful consideration.
The upload script must perform the following checks for the i-th uploaded file (i = 0…n-1 where n is the number of all files):
- if there is no upload error: $_FILES[‘userfile’][‘error’][i] === 0
- if the file size is within predefined limits: $_FILES[‘userfile’][‘size’][i] < $limit
- if the file MIME type is consistent and allowed: $_FILES[‘userfile’][‘type’][i]
- if the file name is a valid file name: $_FILES[‘userfile’][‘name’][i]
- if a file with this name does not already exist: $_FILES[‘userfile’][‘name’][i]
If any of the above checks fails, the upload script must abort and return an error message.
Example for an upload script:
<?php //source: http://www.php.net/manual/en/features.file-upload.php //prepare to deliver text content header('Content-Type: text/plain; charset=utf-8'); try { // Undefined | Multiple Files | $_FILES Corruption Attack // If this request falls under any of them, treat it invalid. if ( !isset($_FILES['userfile']['error']) || is_array($_FILES['userfile']['error']) ) { throw new RuntimeException('Invalid parameters.'); } // Check $_FILES['userfile']['error'] value. switch ($_FILES['userfile']['error']) { case UPLOAD_ERR_OK: break; case UPLOAD_ERR_NO_FILE: throw new RuntimeException('No file sent.'); case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: throw new RuntimeException('Exceeded filesize limit.'); default: throw new RuntimeException('Unknown errors.'); } // You should also check filesize here. if ($_FILES['userfile']['size'] > 1000000) { throw new RuntimeException('Exceeded filesize limit.'); } // DO NOT TRUST $_FILES['userfile']['mime'] VALUE !! // Check MIME Type by yourself. $finfo = new finfo(FILEINFO_MIME_TYPE); if (false === $ext = array_search( $finfo->file($_FILES['userfile']['tmp_name']), array( 'jpg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', ), true )) { throw new RuntimeException('Invalid file format.'); } // You should name it uniquely. // DO NOT USE $_FILES['userfile']['name'] WITHOUT ANY VALIDATION !! // On this example, obtain safe unique name from its binary data. if (!move_uploaded_file( $_FILES['userfile']['tmp_name'], sprintf('./uploads/%s.%s', sha1_file($_FILES['userfile']['tmp_name']), $ext ) )) { throw new RuntimeException('Failed to move uploaded file.'); } echo 'File is uploaded successfully.'; } catch (RuntimeException $e) { echo $e->getMessage(); } ?>
An exhaustive list of recommendations that need to be taken into account can be found here:
http://stackoverflow.com/questions/256172/what-is-the-most-secure-method-for-uploading-a-file
1. Allow only authorized users to upload files. Add a captcha to hinder bots.
2. Set the MAX_FILE_SIZE in your upload form. Set the maximum file size and count on the server.
ini_set('post_max_size','40M'); //may be bigger for multiple files ini_set('upload_max_filesize','40M'); ini_set('max_file_uploads',10);
3. Check file size of uploaded files:
if($fileInput['size']> $sizeLimit){}; //handle size error here
4. Use $_FILES and move_uploaded_file() to put your uploaded files into the right directory, or if you want to process it, then check with is_uploaded_file(). (These functions exist to prevent file name injections caused by register_globals.)
$uploadStoragePath ='/file_storage'; $fileInput = $_FILES['image']; if(fileInput['error']!= UPLOAD_ERR_OK) {}; //handle upload error here $temporaryName = $fileInput['tmp_name']; $extension = pathinfo($fileInput['name'], PATHINFO_EXTENSION); //mime check, chmod, etc. here $name = bin2hex(mcrypt_create_iv(32, MCRYPT_DEV_URANDOM)); //create a random file name move_uploaded_file($temporaryName, $uploadStoragePath .'/'. $name .'.'. $extension);
5. Always generate a random id instead of using the original file name.
6. Create a new subdomain for example http://static.mysite.com or at least a new directory outside of the public_html, for the uploaded files. This subdomain or directory should not execute any file. Set it in the server config, or set in a .htaccess file by the directory (accepting penalty).
SetHandler none SetHandlerdefault-handler Options-ExecCGI php_flag engine off
7. Set it with chmod() as well.
$noExecMode =0644;
chmod($uploadedFile, $noExecMode);
Use chmod() on the newly uploaded files too and set it on the directory.
You should check the mime type sent by the hacker. You should create a whitelist of allowed mime types. Allow images only if any other format is not necessary. Any other format is a security threat. Images too, but at least we have tools to handle them…
The corrupted content for example: HTML in an image file can cause XSS by browsers with content sniffing vulnerability. When the corrupted content is a PHP code, then it can be combined with aneval injection vulnerability.
$userContent =’../uploads/malicious.jpg’;
include(‘includes/’.$userContent);
Try to avoid this, for example use a class autoloader instead of including php files manually…
By handling the javascript injection at first you have to turn off xss and content sniffing in the browsers. Content sniffing problems are typical by older msie, I think the other browsers filter them pretty well. Anyways you can prevent these problems with a bunch of headers. (Not fully supported by every browser, but that’s the best you can do on client side.)
Strict-Transport-Security: max-age={your-max-age}
X-Content-Type-Options: nosniff
X-Frame-Options: deny
X-XSS-Protection:1; mode=block
Content-Security-Policy:{your-security-policy}
You can check if a file is corrupted with Imagick identify, but that does not mean a complete protection.
try{
$uploadedImage =newImagick($uploadedFile);
$attributes = $uploadedImage->identifyImage();
$format = $image->getImageFormat();
var_dump($attributes, $format);}catch(ImagickException $exception){//handle damaged or corrupted images}
If you want to serve other mime types, you should always force download by them, never include them into webpages, unless you really know what you are doing…
X-Download-Options: noopen
Content-Disposition: attachment; filename=untrustedfile.html
It is possible to have valid image files with code inside them, for example in exif data. So you have topurge exif from images, if its content is not important to you. You can do that with Imagick or GD, but both of them requires repacking of the file. You can find an exiftool as an alternative. I think the simplest way to clear exif, is loading images with GD, and save them as PNG with highest quality. So the images won’t lose quality, and the exif tag will be purged, because GD cannot handle it. Make this with images uploaded as PNG too…
If you want to extract the exif data, never use preg_replace() if the pattern or replacement is from the user, because that will lead to an eval injection… Use preg_replace_callback() instead of the eval regex flag, if necessary. (Common mistake in copy paste codes.) Exif data can be a problem if your site has an eval injection vulnerability, for example if you use include($userInput) somewhere.
Never ever use include(), require() by uploaded files, serve them as static or use file_get_contents() or readfile(), or any other file reading function, if you want to control access.
It is rarely available, but I think the best approach to use the X-Sendfile: {filename} headers with the sendfile apache module. By the headers, never use user input without validation or sanitization, because that will lead to HTTP header injection.
If you don’t need access control (means: only authorized users can see the uploaded files), then serve the files with your webserver. It is much faster…
Use an antivir to check the uploaded files, if you have one.
Always use a combined protection, not just a single approach. It will be harder to breach your defenses…
See also:
Unrestricted file upload
Users may upload various files using http or ftp. Uploading executable files or files containing php scripts is a major security issue since some of them may be malicious. Sometimes it is important to prevent accessing and downloading of the files by unauthorized users.
Solution:
Isolate uploaded files by uploading them to dedicated directories above www root where they cannot be accessed (downloaded or run) by users. Only your scripts will access and serve the uploaded files in a controlled manner.
- If it is not possible to upload files above www root then use dedicated directories under the root but disable directory indexes and script execution (having in mind performance penalty) by using .htaccess file:
Options -Indexes Options -ExecCGI AddHandler cgi-script .php .php3 .php4 .php5 .pl .py .shtml .sh .cgi
Options -Indexes stops the directory list from being shown;
Options -ExecCGI disables cgi execution;
AddHandler cgi-script tells the server to treat the following files as cgi scripts thus disabling their execution and to serve them as plain-text files. -
The above method may not be feasible if the server settings do not allow the AddHandler directive, the result being Internal Error (500). In this case the following .htaccess file may be used:
RemoveHandler .cgi .pl .py .php4 .pcgi4 .php .php3 .phtml .pcgi .php5 .pcgi5 RemoveType .cgi .pl .py .php4 .pcgi4 .php .php3 .phtml .pcgi .php5 .pcgi5
- Finally, any request for accessing executable files in the dedicated directory will be rejected, the server returning Not Found (404), if the .htaccess file contains:
<FilesMatch "\.(pl|cgi|py|php|php3|php4|php5|phtml?|shtml?)$"> deny from all </FilesMatch>
or its concise version:
<FilesMatch "\.(cgi|p[ly]|php[345]?|[sp]html?)$"> deny from all </FilesMatch>