I've read various posts about how letting users to upload files can create vulnerabilities to your website such as a user injecting PHP code in an image.

So I've created a small test project where you can upload (outside of web root) and see uploaded images keeping it as simple as I could having in mind security but I'm not an expert and it would be really helpful if you could answer some of my questions and tell me if something could be done better.

  1. Do you spot anything wrong regarding permissions?

  2. Are the checks that I do in upload_images.php to check that the files that are being uploaded are images of the allowed formats sufficient? Could I do something better?

  3. Fetching multiple images using base64_encode(file_get_contents($images[$i])) seems a bit slow and also the string that is being put inside img src is huge...can this be a problem (for example images don't appear in xiaomis MIUI browser)? Is there a better alternative?

  4. Let's say that a malicious image bypasses my checks during uploading. When I fetch an images using the following PHP code get the response in js using ajax and then append it to the dom to be shown to the user using <img src='data:"+ data.extention[i] +";base64," + data.images[i] + "'> is it possible to be harmful in any way?

  5. Is storing images outside of root trying to prevent access of malicious users too much of a hassle? Is it better maybe (security-speed-browser compatibility wise) to just store them inside root and make use of .htaccess to prevent someone from doing harm? Would an .htaccess like the following ( secure_images/.htaccess ) be sufficient for that purpose?

Structure

/
     public_html (root) 755
          .htaccess 444
          index.php 644
          images.php 644
          javascript 755
               show_images.js 644
               upload.js 644
          php_scripts 755
               fetch_images.php 600
               upload_images.php 600
               logo.png 644
     secure_images
          .htaccess 444
          201811051007191220027687.jpg 644
          20181105100719574368017.jpeg 644
     secure_php_scripts 500
          fetch_images.php 600
          upload_images.php 600

public_html/.htaccess

#Deny access to .htaccess files
<Files .htaccess>
order allow,deny
deny from all
</Files>

#Enable the DirectoryIndex Protection, preventing directory index listings and defaulting
Options -Indexes
DirectoryIndex index.html index.php /index.php

#Trackback Spam protection
RewriteCond %{REQUEST_METHOD} =POST
RewriteCond %{HTTP_USER_AGENT} ^.*(opera|mozilla|firefox|msie|safari).*$ [NC]
RewriteCond %{THE_REQUEST} ^[A-Z]{3,9}\ /.+/trackback/?\ HTTP/ [NC]
RewriteRule .? - [F,NS,L]

Upload images related files

public_html/index.php

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Image upload security test</title>
  <meta name="description" content="Image upload security test">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
</head>
<body>
    <form id="upload_form" method="post" enctype="multipart/form-data">
        Select image to upload:
        <input type="file" name="filesToUpload[]" id="filesToUpload" multiple>
        <input type="submit" value="Upload Image" name="submit">
    </form>
    <a href="images.php">See images</a>
    <script src="js/upload.js"></script> 
</body>
</html>

public_html/javascript/upload.js

$("#upload_form").submit(function(e)
    {
        e.preventDefault();
        $.ajax({
            url:"../php_scripts/upload_images.php",
            method:"POST",
            data:new FormData(this),
            processData: false,
            contentType: false,
            dataType:"JSON",
            success:function(data)
            {
                if(data.outcome) { console.log("Images succesfully uploaded"); }
                else { console.log(data.msg); }
            }
        });
    });

public_html/php_scripts/upload_images.php

<?php
include_once($_SERVER['DOCUMENT_ROOT'] . "/../secure_php_scripts/upload_images.php");

secure_php_scripts/upload_images.php

<?php
$uploaded_images[0]["path"] = null;
try
{
    $isValid = validateArray($_FILES['filesToUpload']);
    if($isValid[0])
    {
        $data['outcome'] = true;
        $data['msg'] = "Images uploaded successfully";
        for($i=0; $i<count($_FILES['filesToUpload']['tmp_name']); $i++)
        {
            $new_name = date('YmdHis',time()).mt_rand() . "." . pathinfo($_FILES['filesToUpload']['name'][$i], PATHINFO_EXTENSION);
            $path_to_be_uploaded_to = $_SERVER['DOCUMENT_ROOT'] . "/../secure_images/" . $new_name;
            if(!chmod($_FILES['filesToUpload']['tmp_name'][$i], 0644) ||
               !move_uploaded_file($_FILES['filesToUpload']['tmp_name'][$i], $path_to_be_uploaded_to)
            )
            {
                $data['outcome'] = false;
                $data['msg'] = "There was an error uploading your file";
                break;
            }
            else
            {
                $uploaded_images[$i]["path"] = $path_to_be_uploaded_to;
            }
        }
    }
    else
    {
        $data['outcome'] = false;
        $data['msg'] = $isValid[1];
    }
    echo json_encode($data);
}
catch (Exception $e)
{
    //If there is an exception delete all uploaded images
    if($uploaded_images[0]["path"] != null)
    {
        foreach($uploaded_images as $item)
        {
            if( file_exists($item["path"]) ) { unlink($item["path"]); }
        }
    }
    // Also delete all uploaded files from tmp folder (Files user uploads first go there)
    foreach($_FILES['filesToUpload']['tmp_name'] as $item)
    {
        if( file_exists($item) ) { unlink($item); }
    }
    $data['outcome'] = false;
    $data['msg'] = "There was an error please try again later";
    echo json_encode($data);
}

// Create a new blank image using imagecreatetruecolor()
// Copy our image to the new image using imagecopyresampled()
// And also add a logo in the process
function add_watermark($path_to_img, $ext)
{
    try {
        if($ext == 'png') { $img = imagecreatefromjpeg($path_to_img); }
        else { $img = imagecreatefromjpeg($path_to_img); }
        $stamp = imagecreatefrompng('logo.png');
        // Set the margins for the stamp and get the height/width of the stamp image
        $marge_right = 10;
        $marge_bottom = 10;
        $sx = imagesx($stamp);
        $sy = imagesy($stamp);

        list($width, $height) = getimagesize($path_to_img);
        $dest_imagex = 900;//width of new image
        $dest_imagey = 900;//height of new image
        $dest_image = imagecreatetruecolor($dest_imagex, $dest_imagey);//create new image
        imagecopyresampled($dest_image, $img, 0, 0, 0, 0, $dest_imagex, $dest_imagey, $width,$height);//#im to $dest_image
        //Now $dest_image is an image of 800x800
        // Copy the stamp image onto our photo using the margin offsets and the photo width to calculate positioning of the stamp.
        imagecopy($dest_image, $stamp, $dest_imagex - $sx - $marge_right, $dest_imagey - $sy - $marge_bottom, 0, 0, $sx, $sy);
        $filename = $path_to_img;
        if($ext == 'png') { if(!imagepng($dest_image, $filename)) { return false;} }
        else { if(!imagejpeg($dest_image, $filename)) { return false;} }
        return true;
    } catch (Exception $e)
    {
        return false;
    }
}

// Checks
// if the element provided is a 2D array with the expected elements (multiple pictures per upload)
// if there is an error
// if file extentions are the allowed ones
// is each file size is bellow 1GB
// if the file number is less than 16
// if file exists and if it was uploaded via HTTP POST
function validateArray($array)
{
    try{
        if( $array && is_array($array) )
        {
            if( !is_array($array['name'])) { return [false, "Wrong array format"]; }
            else { $pic_number = count($array['name']); }

            if($pic_number > 15) { return [false, "Maximum image number allowed is 15"]; }

            if( !is_array($array['type']) || count($array['type']) != $pic_number ||
                !is_array($array['tmp_name']) || count($array['tmp_name']) != $pic_number ||
                !is_array($array['error']) || count($array['error']) != $pic_number ||
                !is_array($array['size']) || count($array['size']) != $pic_number
            ) { return [false, "Wrong array format"]; }

            $allowedExts = array('png', 'jpeg', 'jpg');
            $allowedExts2 = array('image/png', 'image/jpg', 'image/jpeg');
            $fileinfo = finfo_open(FILEINFO_MIME_TYPE);
            for($i=0; $i<count($array['name']); $i++)
            {
                if( is_array($array['name'][$i]) || is_array($array['tmp_name'][$i]) ||
                    is_array($array['error'][$i]) || is_array($array['size'][$i])
                ) { return [false, "Wrong array format"]; }
                $ext = pathinfo($array['name'][$i], PATHINFO_EXTENSION);
                if( !in_array($ext, $allowedExts) ) { return [false, "Only PNG JPEG JPG images are allowed"]; }
                if(!file_exists($array['tmp_name'][$i]) || !is_uploaded_file($array['tmp_name'][$i])) { return [false, "File doesn't exists, try again"]; }
                if(!is_uploaded_file($array['tmp_name'][$i])) { return [false, "File has to be uploaded using our form"]; }
                if(!exif_imagetype($array['tmp_name'][$i])) { return [false, "Only images allowed"]; }
                if(filesize($array['tmp_name'][$i]) < 12) { return [false, "All images has to be more than 11 bytes"]; }
                if (!in_array(finfo_file($fileinfo, $array['tmp_name'][$i]), $allowedExts2)) { return [false, "Only PNG JPEG JPG images are allowed"]; }
                if($array['error'][$i] !== 0) { return [false, "File error"]; }
                if($array['size'][$i] > 1000000) { return [false, "Maximum image size allowed is 1GB"]; }
                if(!add_watermark($array['tmp_name'][$i], $ext)) { return [false, "There was an error uploading your file"]; }
            }
        }
        else { return [false, "Element provided is not a valid array"];}
        return [true, "Chill dude images are ok"];
    } catch (Exception $e)
    {
        return [false, "There was an error please try again later"];
    }
}

Show images related files

public_html/images.php

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Image upload security test</title>
  <meta name="description" content="Image upload security test">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
  <style>
      section{display:block;text-align:center;}
      content{display:inline-block;margin:10px;height:400px;width:400px;}
      content img{max-height:100%;max-width:100%;min-height:100%;min-width:100%;}
  </style>
</head>
<body>
    <section>
    </section>
    <script src="js/show_images.js"></script>
</body>
</html>

public_html/javascript/show_images.js

window.onload = function() {
  $.ajax({
            url:"../php_scripts/fetch_images.php",
            method:"POST",
            dataType:"JSON",
            success:function(data)
            {
                if(data.outcome) {
                    if(data.images)
                    {
                        if(data.images.length > 0)
                        {
                            let text = [];
                            for( let i=0; i< data.images.length; i++)
                            {
                             text[i] = "<content><img src='data:"+ data.extention[i] +";base64," + data.images[i] + "'></content>";
                            }
                            $("section").append(text);
                        }
                    }
                    else {console.log("no images found"); }
                }
                else { console.log("An error occured please try again later"); }
            }
        });
};

public_html/php_scripts/fetch_images.php

<?php
include_once($_SERVER['DOCUMENT_ROOT'] . "/../secure_php_scripts/fetch_images.php");

secure_php_scripts/fetch_images.php

<?php
try
{
    $data["outcome"] = true;
    $directory = $_SERVER['DOCUMENT_ROOT'] . "/../secure_images/";
    $images = glob($directory . "*.{[jJ][pP][gG],[pP][nN][gG],[jJ][pP][eE][gG]}", GLOB_BRACE);
    $fileinfo = finfo_open(FILEINFO_MIME_TYPE);
    for ($i = 0; $i < count($images); $i++)
    {
        $extention = finfo_file($fileinfo, $images[$i]);
        header('Content-Type: ' . $extention);
        $data["extention"][$i] = $extention;
        $data["images"][$i] = base64_encode(file_get_contents($images[$i]));
    }
    echo json_encode($data);
} catch(Exception $e)
{
    $data["outcome"] = false;
    $data["images"][0] = [];
    echo json_encode($data);
}

secure_images/.htaccess

#Deny access to .htaccess files
<Files .htaccess>
order allow,deny
deny from all
</Files>

#Enable the DirectoryIndex Protection, preventing directory index listings and defaulting
Options -Indexes
DirectoryIndex index.html index.php /index.php

#Securing directories: Remove the ability to execute scripts
AddHandler cgi-script .php .pl .py .jsp .asp .htm .shtml .sh .cgi
Options -ExecCGI
New contributor
captain monk is a new contributor to this site. Take care in asking for clarification, commenting, and answering. Check out our Code of Conduct.

This question has an open bounty worth +100 reputation from captain monk ending in 4 days.

Looking for an answer drawing from credible and/or official sources.

This is an issue that has been bothering me for quite some time and it would be really helpful if someone knowledgeable could elaborate

  • 2
    I would recommend to split your post into several smaller questions. Uploading images is one thing while displaying is another. – Your Common Sense Nov 8 at 10:03
  • 2
    In a few words, the PHP part is overly-overcomplicated and bloated. There are a lot of excessive loops, duplicated verifications and useless exceptions. – Your Common Sense Nov 8 at 10:06
  • I will edit it later having in mind your feedback. thank you for the info – captain monk Nov 8 at 10:16
  • 3
    @YourCommonSense But the problem is in displaying an uploaded image. It's safe to upload images to a place that doesn't display them. It's safe to display images that you put there. What's not safe is displaying an image that someone else uploaded. Splitting the two makes it much harder to review the actual problem: securing uploaded images while still making them available for display. And if you think that the code is overly-complicated and bloated, write an answer showing how that is so and how to fix it. – mdfst13 Nov 9 at 16:57

First, to your questions

Do you spot anything wrong regarding permissions?

Overkill. Permissions has very little to do with web-servers, as there is only one user - one under which a web-server (or a php process) runs. So it doesn't really matter what number you have a fancy to set.

Are the checks that I do in upload_images.php to check that the files that are being uploaded are images of the allowed formats sufficient? Could I do something better?

Overkill. All these checks do not prevent the upload of the valid PHP code. But honestly, it doesn't really matter.

Fetching multiple images using base64_encode(file_get_contents($images[$i])) seems a bit slow and also the string that is being put inside img src is huge...can this be a problem (for example images don't appear in xiaomis MIUI browser)? Is there a better alternative?

Yes, it prevents the caching on the client side and causes a huge bandwidth waste.
It's better to show images as is.

Let's say that a malicious image bypasses my checks during uploading. When I fetch an images using the following PHP code get the response in js using ajax and then append it to the dom to be shown to the user using is it possible to be harmful in any way?

Not on the PHP side. I am not a JS expert though. You can try security.stackexchange.com for this. Taken alone, without all this wall of code, it will make a perfect question there (if not a duplicate though).

Is storing images outside of root trying to prevent access of malicious users too much of a hassle? Is it better maybe (security-speed-browser compatibility wise) to just store them inside root and make use of .htaccess to prevent someone from doing harm? Would an .htaccess like the following ( secure_images/.htaccess ) be sufficient for that purpose?

Yes allowing a web-server to handle images would be a way better way.

Now to the actual code review

The whole code is too big for the full review though, because it covers too many irrelevant topics, like secure image upload, file permissions, web security in general, client-side programming, and even for some reason creating watermarks. So I'll cover just the actual image upload.

validateArray() function

  1. Exceptions misused. For some reason, instead of catching exceptions outside of the function, you are catching them inside, which makes both the function's code and its output more complicated. Just throw inside the function and catch outside. That's all. No need to even return true, as in case of the error, the execution won't even reach the condition where the return value is checked.
  2. You are duplicating A LOT of functionality provided by PHP. Most of errors you are checking for can be thrown by PHP. For example if you will try to iterate over a string, PHP will give you an error. So it makes no sense do all the numerous verifications. PHP can do it for you. All you need is a simple error handler that can convert PHP errors to exceptions and poof - you already have the exception without a single line of code!
  3. The same goes for the numerous file verifications. If a file doesn't exist, PHP will tell you that!
  4. Finfo and exif can be easily fooled, a file which is a valid image could be a no less valid PHP script at the same time. So use them for your convenience only, but these functions won't add too much security.
  5. Watermarks has nothing to do with validation.
  6. Formatting. PSR-2 is a de-facto standard now so you are supposed to follow it.

So in your place I would make this function like this

function validateArray($array)
{
    if(count($array['name']) > 15) {
        throw new MyFileUploadException("Maximum image number allowed is 15");
    }
    $allowedExts = array('png', 'jpeg', 'jpg');
    foreach($array['name'] as $i => $name)
    {
        if($array['error'][$i] !== 0) {
            throw new MyFileUploadException("File error");
        }
        if(!is_uploaded_file($array['tmp_name'][$i])) {
            throw new MyFileUploadException("File has to be uploaded using our form");
        }
        $ext = pathinfo($name, PATHINFO_EXTENSION);
        if( !in_array($ext, $allowedExts) )
        {
            throw new MyFileUploadException("Only PNG JPEG JPG images are allowed");
        }
        if(filesize($array['tmp_name'][$i]) < 12) {
            throw new MyFileUploadException("All images has to be more than 11 bytes");
        }
        if($array['size'][$i] > 1000000) {
            throw new MyFileUploadException("Maximum image size allowed is 1GB");
        }
    }
    return true;
}

upload_images.php code

Issues are the same: too much duplicated verifications and exceptions misuse which leads to the duplicated code.

Besides , there is no point in deleting tmp_files and also I doubt we should delete already uploaded files as well. So to me the code would be rather

class MyFileUploadException extends Exception {};
try
{
    validateArray($_FILES['filesToUpload']);
    foreach($_FILES['filesToUpload']['tmp_name'] as $i => $tmp_name)
    {
        $ext = pathinfo($_FILES['filesToUpload']['name'][$i], PATHINFO_EXTENSION);
        $new_name = date('YmdHis',time()).mt_rand() . "." . $ext;
        $path_to_be_uploaded_to = $_SERVER['DOCUMENT_ROOT'] . "/../secure_images/" . $new_name;
        move_uploaded_file($tmp_name, $path_to_be_uploaded_to);
        add_watermark($path_to_be_uploaded_to, $ext);
    }
    $data['outcome'] = true;
    $data['msg'] = "Images uploaded successfully";
}
catch (MyFileUploadException $e)
{
    $data['outcome'] = false;
    $data['msg'] = $e->getMessage();
}
catch (Exception $e)
{
    error_log($e);
    $data['outcome'] = false;
    $data['msg'] = "There was an error please try again later";
}
echo json_encode($data);

Notice the user-defined exception and the difference in the processing. Your own error messages thrown via MyFileUploadException are useful for the user and do no harm when revealed - so the message is conveyed to the user as is.

Whereas PHP's internal error messages are exactly the opposite: too cryptic for the site user but may contain some sensitive information that shouldn't be revealed outside. At the same time they are vital for the site programmer - so they are logged in the web-server's error error log, while a generalized error message is shown to the user. So now errors are treated according to the best standards which are explained in my article I linked above.

I would question the name generation method though, and use md5() from the file contents instead.

Your Answer

captain monk is a new contributor. Be nice, and check out our Code of Conduct.
 

By clicking "Post Your Answer", you acknowledge that you have read our updated terms of service, privacy policy and cookie policy, and that your continued use of the website is subject to these policies.

Not the answer you're looking for? Browse other questions tagged or ask your own question.