Hello!

Occasionally it was necessary for us to lock down some or all of the WordPress media library from public viewing, indexing. The reasons why this would be necessary can vary from sensitive information leakage to private user information protection (i.e. custom user media files uploaded on a per user account basis).

Either way, there is a relatively straightforward way to lock down the visibility and permissions of files or folders in your media library from either being indexed (And disclosed more easily to the public for access) or randomly accessed through browsing the wp-content/uploads folder. Remember this folder usually (by default) has directory index enabled. This means you can usually visit a WordPress site, manually access the site.com/wp-content/uploads folder and browse the files and folders therein in order to see if any sensitive information is contained within.

Sometimes its not enough to simply edit your robots.txt to not allow Google and other search engines to index your site. Having a sensitive PDF or Word document show up in google results is obviously not ideal, however it is very trivial for someone to manually scan your uploads folder to enumerate any sensitive documents which increases risks like information disclosure and confidentiality issues.

How would we address this? The solution I would propose here is twofold : Modify the default uploads destination folders to include logic to separate media uploads for custom content or media that is uploaded on a per user basis into separate folders. Doing this will lay the groundwork for us to restrict media that is uploaded under certain conditions.

Change Media Upload Default Folder

There are many methods to accomplish this. One way would be to modify your theme’s functions.php file to include code to modify the upload folder based on certain conditions. Those conditions could be if the user belongs to a specific role / group or if the hey a specific capability :

function change_upload_dir($dir) { if (is_user_logged_in()) { global $current_user; get_currentuserinfo(); $upload_dir = wp_upload_dir(); $user_dirname = $upload_dir['basedir'].'/'.$current_user->user_login; if ( ! file_exists( $user_dirname ) ) { wp_mkdir_p( $user_dirname ); } } } add_filter( 'upload_dir', 'change_upload_dir' ); 1 2 3 4 5 6 7 8 9 10 11 12 function change_upload_dir ( $ dir ) { if ( is_user_logged_in ( ) ) { global $ current_user ; get_currentuserinfo ( ) ; $ upload_dir = wp_upload_dir ( ) ; $ user_dirname = $ upload_dir [ 'basedir' ] . '/' . $ current_user - & gt ; user_login ; if ( ! file_exists ( $ user _ dirname ) ) { wp_mkdir_p ( $ user _ dirname ) ; } } } add_filter ( 'upload_dir' , 'change_upload_dir' ) ;

You can see with the above snippet that anything can be possible with this. To check for a specific role, you simply can access the global variable $current_user and check what the assigned role is :

$user_roles = $current_user->roles; $user_role = array_shift($user_roles); if ($user_role == 'whatever') { echo 'you are whatever'; } 1 2 3 4 5 $ user_roles = $ current_user - & gt ; roles ; $ user_role = array_shift ( $ user_roles ) ; if ( $ user_role == 'whatever' ) { echo 'you are whatever' ; }

This will make the foundation of all the changes we’ll make later on to lock down the WordPress media folder. Now another option to accomplish all of this, if you’re less PHP inclined, would be to simply install a plugin to do all the work. A great plugin to accomplish the same thing as the above would be the Custom Upload Dir plugin.

This plugin adds an option to your administrative interface to manipulate the default uploads folder. This is a great plugin because it allows you to use placeholder tags to create a truly unique and predictable upload directory structure :

%file_type% => The file type %file_ext% => The file extension %post_id% => The post ID %author% => The post author %author_role% => The post author's role %postname% => The post's URL slug %parent_name% => The parent URL slug %post_type% => (post|page|attachment) %year% => The post's year (YYYY) %monthnum% => The post's month (MM) %day% => The post's day (DD) %permalink% => Match your blog's permalink structure %current_user% => The currently logged in user %category% => The post's categories (see: Taxonomies) %post_tag% => The post's tags (see: Taxonomies) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 % file_type % =& gt ; The file type % file_ext % =& gt ; The file extension % post_id % =& gt ; The post ID % author % =& gt ; The post author % author_role % =& gt ; The post author 's role %postname% => The post' s URL slug % parent_name % =& gt ; The parent URL slug % post_type % =& gt ; ( post | page | attachment ) % year % =& gt ; The post 's year (YYYY) %monthnum% => The post' s month ( MM ) % day % =& gt ; The post 's day (DD) %permalink% => Match your blog' s permalink structure % current_user % =& gt ; The currently logged in user % category % =& gt ; The post 's categories (see: Taxonomies) %post_tag% => The post' s tags ( see : Taxonomies )

The keyword here is predictable. We will eventually be building logic into WordPress to set a session cookie if the user belongs to a User Role/Group. Then we will tell Nginx or Apache to refuse direct file access to those folders if the cookie is not present.

So lets say we had a custom post type called “custom-content” and we wanted to protect all of the media uploaded only for that content. Using the above plugin, we would configure a custom upload directory like the following :

/wp-content/uploads/%post_type%/%author%/%author_role/%file_type%/%monthnum%%day%%year%/ 1 / wp - content / uploads / % post_type % / % author % / % author_role / % file_type % / % monthnum % % day % % year % /

So what would that do? If I uploaded a media file for my custom content called “custom-content” then it would be located in the following folder :

/wp-content/uploads/custom-content/admin/administrator/JPEG/05132016/filename.jpg 1 / wp - content / uploads / custom - content / admin / administrator / JPEG / 05132016 / filename . jpg

Having a predictable URI structure means that we can tell Nginx or Apache to perform certain conditional checks based on the URI path. We’re not there yet! We need to set the session cookie first.

Set a session cookie to protect WordPress media

There may be plugins to accomplish setting a custom session cookie but I prefer to do it custom in functions.php because it will allow me to set all the options. This is especially useful when we are dealing with custom content + custom session cookies. Plugins can only be so versatile and flexible before (usually) coming short of what they need. If they dont fall short, then the extra features you need are in a “premium” version of the plugin.

So lets define a function in functions.php and add it to the init hook to create the cookie :

// set cookie if user belongs to group function set_custom_cookie() { if (is_user_logged_in()) { global $current_user; $user_roles = $current_user->roles; $user_role = array_shift($user_roles); if ($user_role == 'whatever') { if (!isset($_COOKIE['custom_cookie'])) { $cookie_value = $current_user->ID . '|' . $current_user->user_login . '|' . $current_user->roles; $salt = wp_salt('auth'); $cookie_hash = hash_hmac('md5', $cookie_value, $salt); setcookie('custom_cookie', $cookie_value, strtotime('+1 day')); } } } else { $cookie_value = $current_user->ID . '|' . $current_user->user_login . '|' . $current_user->roles; $salt = wp_salt('auth'); $cookie_hash = hash_hmac('md5', $cookie_value, $salt); if ($cookie_hash != $_COOKIE['custom_cookie']) { unset($_COOKIE['custom_cookie']); setcookie('custom_cookie', '', time()-3600, '/'); } } else { // If user is not logged in , clear cookie every time if (isset($_COOKIE['custom_cookie'])) { unset($_COOKIE['custom_cookie']); setcookie('custom_cookie', '', time()-3600, '/'); } } } add_action('init', 'set_custom_cookie'); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // set cookie if user belongs to group function set_custom_cookie ( ) { if ( is_user_logged_in ( ) ) { global $ current_user ; $ user_roles = $ current_user - & gt ; roles ; $ user_role = array_shift ( $ user_roles ) ; if ( $ user_role == 'whatever' ) { if ( ! isset ( $ _COOKIE [ 'custom_cookie' ] ) ) { $ cookie_value = $ current_user - & gt ; ID . '|' . $ current_user - & gt ; user _ login . '|' . $ current_user - & gt ; roles ; $ salt = wp_salt ( 'auth' ) ; $ cookie_hash = hash_hmac ( 'md5' , $ cookie_value , $ salt ) ; setcookie ( 'custom_cookie' , $ cookie_value , strtotime ( '+1 day' ) ) ; } } } else { $ cookie_value = $ current_user - & gt ; ID . '|' . $ current_user - & gt ; user _ login . '|' . $ current_user - & gt ; roles ; $ salt = wp_salt ( 'auth' ) ; $ cookie_hash = hash_hmac ( 'md5' , $ cookie_value , $ salt ) ; if ( $ cookie_hash != $ _COOKIE [ 'custom_cookie' ] ) { unset ( $ _COOKIE [ 'custom_cookie' ] ) ; setcookie ( 'custom_cookie' , '' , time ( ) - 3600 , '/' ) ; } } else { // If user is not logged in , clear cookie every time if ( isset ( $ _COOKIE [ 'custom_cookie' ] ) ) { unset ( $ _COOKIE [ 'custom_cookie' ] ) ; setcookie ( 'custom_cookie' , '' , time ( ) - 3600 , '/' ) ; } } } add_action ( 'init' , 'set_custom_cookie' ) ;

Whats going on here? Well we’re just triggering a custom function on WordPress init to create a session cookie (if it doesn’t already exist) called custom_cookie. This cookie is ONLY set if the logged in user belongs to the custom role called “Whatever”. You can see the possibilities here. We can set any condition we want, the possibilities are endless. This again is the advantage of implementing your own code instead of finding a plugin.

Okay lets recap what we’ve done so far :

1. We defined a custom uploads directory which identifies a predictable folder structure that will allow us to create a check in Nginx or Apache for a specific session cookie

2. We create and apply a session cookie if the logged in user meets specific conditions

Configure Apache or Nginx to check for the cookie if the request URI matches

Just like the title says, we want to tell Nginx or Apache to check for that session cookie if a user tries to request a file that matches the path in question. Remember the key here is that session cookie wouldn’t be present if the conditions didn’t match in our functions.php. Additional security to prevent cookie forgery could be implemented such as specific values in the cookie value that can be verified such as authentication or user ID values. I’ll leave that up to you because if you’ve made it this far, you should be able to investigate additional secure methods of implementation beyond the basic overview provided here.

For Nginx , here is a sample configuration directive you could try as a starting point :

location ~ ^/wp-content/uploads/custom-content/(.*) { if ($http_cookie !~ 'custom_cookie=2') { # If the device-pixel-ratio cookie is not set to 2, fall back to # default behaviour, i.e. don't try to serve high resolution image return 301 http://www.yoursite.com; } } 1 2 3 4 5 6 7 location ~ ^ / wp - content / uploads / custom - content / ( . * ) { if ( $ http_cookie ! ~ 'custom_cookie=2' ) { # If the device-pixel-ratio cookie is not set to 2, fall back to # default behaviour, i.e. don't try to serve high resolution image return 301 http : //www.yoursite.com; } }

In the above config directive snippet, we are checking for the presence of the custom_cookie cookie (and if it has a value of “2” , which could be anything). This cookie check only happens if the URI path matches /wp-content/uploads/custom-content. You can use more fancy Regex to match author names or whatever combination of conditions that suits your needs.

For apache, we will be doing something similar with Rewrite rules :

RewriteCond %{REQUEST_URI} ^/wp-content/uploads/custom-content/(.*) RewriteCond %{HTTP_COOKIE} =([^;]+) RewriteRule .* http://www.yoursite.com [R=301,L] 1 2 3 RewriteCond % { REQUEST_URI } ^ / wp - content / uploads / custom - content / ( . * ) RewriteCond % { HTTP_COOKIE } = ( [ ^ ; ] + ) RewriteRule . * http : //www.yoursite.com [R=301,L]

In both the Apache and Nginx examples, we are doing a 301 redirect to the homepage if the condition match fails. The advantage of not providing a 403 forbidden or 500 series error is that the end-user (unless they pry or manipulate host headers or cookies) will not be able to easily see what happened. They will just know that they attempted to view a file and were redirected to the homepage. If a 403 forbidden error was provided then they may be more inclined to pry further and it would be easier for them to differentiate accessible files from inaccessible files for penetration testing purposes.

I hope this guide helps!