Blog

How to protect WordPress media files and only allow the users who uploaded them to view

Hello!

In the past we have written about how to protect your WordPress media files. In the past exercises we utilized a strategy to set a session cookie with encrypted details that can be read and validated at the http service (i.e. nginx) as well as application (php/wordpress) level.

Since then we have refined this process to be much more secure, flexible and efficient. We have abandoned the cookie validation process for verifying the request before serving it and replaced it with a slightly more complicated but much more secure method.

First before getting into the details, why would we want to protect WordPress media files? Well the answer depends on what sort of site you have and what you are trying to do, obviously.

In our scenario, which isn’t necessarily unique, we have end-users that register for an account in order to check out of a Woocommerce store. We are requiring the end-users to upload a digital file that is associated with their account. We only want them to be able to access the file they uploaded, as well as WordPress administrators who may want to verify their account.

This means that things like anonymous users, google crawlers and other authenticated users must be blocked from accessing these files. Most importantly of all, we dont want to impact the “normal” use of the WordPress media library. In the link at the beginning of this article you can explore the original post where we go into how to utilize Advanced custom  fields to create a customer user profile field and ensure files uploaded are sent to a specific folder.

In this post I want to outline where the strategy differs from our original post in 2016.

Use nginx to rewrite and reverse proxy requests for WordPress media files

This is the first key step to ensuring all requests to your custom media uploaded files are served securely. We are going to be using nginx to match the location of the custom wordpress media folders, rewrite the request and proxy the request back to itself. The reverse proxy is important because we dont want the URL to change in the browser nor do we want the request to result in a 301 browser redirect.

        # Forward secure media file requests to custom php handler
        location ^~ /wp-content/uploads/custom-media-files {
            rewrite ^/wp-content/uploads/custom-media-files/(.*)/(.*) /wp-content/themes/yourtheme/shift8_mediaverify.php?usrid=$1&fn=$2 break;
            proxy_pass http://127.0.0.1:80;
            proxy_redirect off;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For-SSL $proxy_add_x_forwarded_for;
            proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
        }

In the above example, the custom media upload folder is /wp-content/uploads/custom-media-files. With the nginx code above, we are matching any request for that URI and proxying the request to a custom PHP file that we will detail below.

Before going further, I should note that we have modified the Advanced Custom Field file upload location to be /wp-content/uploads/custom-media-files//. You can read more about how to change the upload destination for an ACF custom upload field by clicking here.

This means that the rewrite rule above is looking for two variables: $1 and $2 respectively. $1 will be the user ID sub folder within the secure upload directory. $2 will be the actual filename. We will interpret these and pass them as query string arguments to our shift8_mediaverify.php file.

Validate requests to WordPress secure media files and serve content if conditions match

This is where the magic happens! So we are using nginx to reverse proxy a request for a secure media file to our PHP file. What we want is the PHP file to bootstrap the WordPress environment so that we can validate the currently logged in user to ensure that they are allowed to access the file. If not, we want to serve a default image (though we can even just redirect them away with a 302 redirect alternatively).

Bootstrap the WordPress environment in a standalone PHP script

This is well documented but I thought I’d outline it here. Our shift8_mediaverify.php script needs to access WordPress core functions as well as authenticated user data in order to properly validate the request.

$parse_uri = explode( 'wp-content', $_SERVER['SCRIPT_FILENAME'] );
ini_set('include_path', $parse_uri[0]);
define( 'WP_USE_THEMES', false );
define( 'BASE_PATH', $parse_uri[0] );
require( $parse_uri[0] . 'wp-load.php' );

The above code should do the trick! We are parsing the path of the site and requiring the wp-load.php file which will provide all the functions and data that we need.

Parse the query strings for the user ID and filename

If I were to consider the part of the code with the highest risk for injection or malicious activity, it would be here :

$usrid = ($_GET['usrid'] ? (int) $_GET['usrid'] : null);
$fname = ($_GET['fn'] ?  filter_var($_GET['fn'], FILTER_SANITIZE_URL) : null);

As with any development, the best practice is to sanitize your input as much as possible. For the user ID, we are only assigning it if its an integer. For the filename, which is somewhat more complicated because many more strange characters are allowed, we are using the built-in php function filter_var to sanitize the input.

Only serve WordPress media files if the user id matches or if they are an administrator

This is where you can get creative based on your own requirements. For us, we wanted the user requesting the file to match the user ID of who originally uploaded it. We also wanted to give WordPress administrators the ability to view the file if needed.

$user_id = get_current_user_id();
if ( $fname && ($usrid && is_int( $usrid )) ) {
    if ($user_id == $usrid || current_user_can('administrator')) {

How to get the WordPress media attachment ID from a URL

Before getting into the next parts of the PHP file, I wanted to give an overview of how to obtain a WordPress media attachment ID from a URL. This function is used to get the image ID so that we can use the WordPress function get_attached_file. There is more than one way to do this and there is some helpful built-in functions but we found this custom function worked best :

function shift8_attachment_url_to_postid( $url ) {
    global $wpdb;
    $sql = $wpdb->prepare(
        "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value = %s",
        $url
    );
    $post_id = $wpdb->get_var( $sql );

    return (int) $post_id;
}

How to serve an image file with PHP

Now that we know how to look up an image ID from a URI, we want to serve an image. This isn’t specific to WordPress however I wanted to detail here for reference :

$imgid = shift8_attachment_url_to_postid('custom-media-files/' . $usrid . '/' . $fname);
        $img_path = get_attached_file ( $imgid );
        if ($img_path && file_exists($img_path)) {
            $img_ext = strtolower(substr(strrchr(basename($img_path), '.'), 1));
            switch($img_ext) {
                case "gif": $img_type="image/gif"; break;
                case "png": $img_type="image/png"; break;
                case "jpeg":
                case "jpg": $img_type="image/jpeg"; break;
                default:
            }
            header('Content-Type: ' . $img_type);
            header('Content-Length: ' . filesize($img_path));
            readfile($img_path);

Thats it! There is always room for improvement but hopefully this overview gives you an idea of how to securely deliver media files and lock down portions of the media upload process in WordPress to authorized users.

Find the shift8_mediaverify.php file in its entirety here :

<?php
$parse_uri = explode( 'wp-content', $_SERVER['SCRIPT_FILENAME'] );
ini_set('include_path', $parse_uri[0]);
define( 'WP_USE_THEMES', false );
define( 'BASE_PATH', $parse_uri[0] );
require( $parse_uri[0] . 'wp-load.php' );

$usrid = ($_GET['usrid'] ? (int) $_GET['usrid'] : null);
$fname = ($_GET['fn'] ?  filter_var($_GET['fn'], FILTER_SANITIZE_URL) : null);
$user_id = get_current_user_id();

if ( $fname && ($usrid && is_int( $usrid )) ) {
    if ($user_id == $usrid || current_user_can('administrator')) {
        $imgid = shift8_attachment_url_to_postid('custom-media-files/' . $usrid . '/' . $fname);
        $img_path = get_attached_file ( $imgid );
        if ($img_path && file_exists($img_path)) {
            $img_ext = strtolower(substr(strrchr(basename($img_path), '.'), 1));
            switch($img_ext) {
                case "gif": $img_type="image/gif"; break;
                case "png": $img_type="image/png"; break;
                case "jpeg":
                case "jpg": $img_type="image/jpeg"; break;
                default:
            }
            header('Content-Type: ' . $img_type);
            header('Content-Length: ' . filesize($img_path));
            readfile($img_path);
        } else {
            shift8_default_prod_img($parse_uri[0]);
        }
    } else {
        shift8_default_prod_img($parse_uri[0]);
    }
} else {
    shift8_default_prod_img($parse_uri[0]);
}

function shift8_default_prod_img($base_path) {
    $imgdf = $base_path . 'wp-content/themes/yourtheme/img/noimageavailable_small.jpg';
    header('Content-Type: image/jpeg');
    header('Content-Length: ' . filesize($imgdf));
    readfile($imgdf);
}