Secure files in WordPress

Secure files in WordPress

WordPress is the content management system that runs about half of all websites. Although WordPress offers a login by default that can be used to regulate access to webpages, the media on these pages are not protected and can easily be scraped by crawlers. By following the steps outlined in this post, WordPress administrators can bolster their site’s security posture, fortifying it against common attack vectors and ensuring the confidentiality of their content.

Key takeaways:

  • All WordPress media are exposed to direct access unless explicitly protected.
  • Using the .htaccess we can instruct the webserver to restrict access to specific media.
  • .htaccess must be used in conjunction with PHP to validate the login cookie.

What is direct access?

When you visit a website, the web server handles your page request and returns the requested webpage. If your web server is Apache, then Apache uses the WordPress installation to construct the requested webpage from file sources, see the diagram below.

If you have ever, on a webpage, have happened to right-click on an image and ‘Copy image path’, or ‘Open image in new tab’, you will have seen that these sources are stored in the file system of the server. When accessing these source files WordPress, and thus the WordPress log-in validation, are bypassed.

Server
example.com
index.php
img.jpg
example.com/img.jpg
img.jpg
Apache
WordPress
File system
User

Diagram of the processing of two types of requests, a web page request on the left and a direct access request on the right. All arrows can also be followed back for the delivery from the File system back to the User

This means that anything in your WordPress installation is exposed to direct access by default, in a Bitnami stack, this is /opt/bitnami/wordpress. This is why the official WordPress documentation advises you to at least secure your wp-admin environment (for a Bitnami implementation see below).

It also means that direct access to content on a webpage cannot be restricted through WordPress, but must happen at a server level.

Exploiting direct access

It is easy to build tools that exploit this vulnerability, especially because WordPress and WordPress plugins use default file paths and names. For example, media are uploaded to /wp-content/uploads/. The plugin ‘Ultimate Member’ stores its uploaded data to /wp-content/uploads/ultimatemember. Users paths are numbered and the profile photos are simply stored under the name profile_photo.png. So you were looking to scrape a profile-picture.png you could easily do so through direct access of example.com/wp-content/uploads/2024/02/profile-picture.png:

Rewriting URL requests with .htaccess

Direct access requests can be redirected using the web server. Apache can perform rule-based requested URLs on the fly. These rules are specified in the .htaccess file. Every ReWriteRule uses a pattern and a target. The pattern allows for granular control over which URLs the rewrite rule applies to. The target which specifies where to redirect to.

The location of the .htaccess file differs depending on your installation. In the Bitnami example used here, the .htaccess file is located in the Apache installation, not in the root directory of your WordPress site. The location of .htaccess influences the scope of the pattern and target. This means that my Bitnami implementations may look somewhat verbose compared to when your .htaccess was located at the root of your WordPress installation. To read up on the background of this solution please see Understand Default .Htaccess File Configuration in the Bitnami docs.

We will now first check if we can get .htaccess to work. Then we will protect wp-includes files. Finally, we will redirect direct media access.

1. Check if .htaccess works

Connect to your web server through SSH and access the following file use any editor. I use the vim editor on my AWS Lightsail instance, so I go to the Lightsail Portal and run the following command. In Bitnami you will find the .htaccess, and can edit .htaccess in the following location:

bitnami@ip-3-67-180-42:~$vim /opt/bitnami/apache/conf/vhosts/htaccess/wordpress-htaccess.conf

Add a new rewrite rule by adding the following code block at the bottom of the file. This RewriteRule redirects all requests to a htacccess_tester.php. More specifically, the first line specifies the scope to /opt/bitnami/wordpress. The regular expression ^.* in the RewriteRule matches everything, and it redirects to the file htaccess_tester.php located within this scope.

wordpress-htaccess.conf
Copy
<Directory "/opt/bitnami/wordpress"> RewriteEngine On RewriteRule ^.*$ htaccess_tester.php </Directory>

Side note: You may find uses of <IfModule mod_rewrite.c> in examples elsewhere to check if rewriting is enabled. This would mean that if it is not enabled, the security code would fail silently. Instead, I rather have the server fail at this point. The content of htaccess_tester.php can be minimal like the example below, or go for something more fancy.

bitnami@ip-3-67-180-42:~$vim /opt/bitnami/wordpress/htaccess_tester.php
htaccess_tester.php
Copy
<h1>Successfull redirect using Apache <tt>.htaccess</tt> to /opt/bitnami/wordpress/htaccess_tester.php</h1>

To let the changes take effect, always restart the Apache server:

bitnami@ip-3-67-180-42:~$sudo /opt/bitnami/ctlscript.sh restart apache

When reloading your file, you should now see your HTML file. The cool thing you may notice is that your browser will tell you you are at example.com, while you are looking at htaccess_tester.php. Now that we have confirmed that .htaccess redirects work you can remove or comment out the code block you added to wordpress-htaccess.conf.

2. Redirect requests to the WordPress admin

What we have learned in the previous step will allow us to protect our files in wp-includes and wp-admin/includes, as advised by the official WordPress documentation. Again, we set the scope using a <Directory /> element. While we are on it, let us protect .json files in addition to .php:

wordpress-htaccess.conf
Copy
# Block requests to all php and json files under /wp-includes/ <Directory "/opt/bitnami/wordpress/wp-includes"> RewriteRule ^[^/]+\.(php|json)$ - [F,L] </Directory> # Block requests to files under /wp-admin/includes/ <Directory "/opt/bitnami/wordpress/wp-admin/includes"> RewriteRule ^(.*)$ [F,L] </Directory>

Don’t forget to restart your Apache server -as shown above- for these changes to take effect.

3. Redirect requests to private media

Now let us do something similar for media files. The only issue is that we cannot just return a 403 error using [F, L] any time an image is requested because we need to be able to return it for use. Therefore, we need to perform a check if users are logged in, and if so, return the image.

WordPress checks if users are logged in using cookies. Cookies are small text files that can contain any number of data fields. The most important fields of a WordPress log in cookie are its name and value pair. The name is static and identifies your specific website to the browser. The value is used by WordPress the check the validity of the cookie, more info here. This is a condensed version of what such a cookie might look like:

cookie.json
Copy
{ "domain": "www.dispuutmagnus.nl", "name": "wordpress_logged_in_32904...", "session": true, "value": "admin%7C1707922719%7Cf45...", }

Just checking for the presence of the cookie using RewriteCond %{HTTP_COOKIE} !^.*wordpress_logged_in_.*$ is insufficient, since this would still grant access to anyone who sets their cookie named wordpress_logged_in_. This is why we will redirect all traffic to a PHP file that will do the login validation for us:

wordpress-htaccess.conf
Copy
# Redirect all /private/ requests to dl-file.php <Directory "/opt/bitnami/wordpress/wp-content/uploads/private"> RewriteCond %{REQUEST_FILENAME} -s RewriteRule ^(.*)$ /opt/bitnami/wordpress/dl-file.php?file=$1?dir=private [QSA,L] </Directory>

We create this PHP file in this location:

bitnami@ip-3-67-180-42:~$vim /opt/bitnami/wordpress/dl-file.php

A solution has been posted elsewhere but this implements access restrictions to all uploaded files. The following is a modification that accepts a dir argument to specify which subdirectory of wp-content/uploads to apply to.

The validation itself is the easy part because it can be handled by WordPress. The rest of the code reconstructs the page from the source destination. The content of dl-file.php is as follows:

dl-file.php
Copy
<?php /* * dl-file.php * Protect uploaded files with login. * @link https://www.maartenpoirot.com/wordpress-secure/ * @author mgpoirot <https://maartenpoirot.com/> * @license GPL-3.0+ */ // Import login module require_once('wp-load.php'); // Check if the user is logged in, if not send to log in screen is_user_logged_in() || auth_redirect(); // Get full file path list($basedir) = array_values(array_intersect_key(wp_upload_dir(), array('basedir' => 1)))+array(NULL); // Get the subdirectory from ?dir $fileParts = explode('?', $_GET[ 'file' ]); $filePath = $fileParts[0]; $dir = ''; if (isset($fileParts[1])) { parse_str($fileParts[1], $query); if (isset($query['dir'])) { $dir = $query['dir']; } } // Construct the directory to hide $basedir .= "/" . $dir; // Construct the actual file path $file = rtrim($basedir, '/') . '/' . str_replace('..', '', $filePath); // If everything is OK, set the document header and read the file if (file_exists($file) && is_readable($file)) { // Set the appropriate Content-Type header header('Content-Type: ' . mime_content_type($file)); // Output the image readfile($file); }

Drawbacks

I have noticed a substantial performance delay on my lightweight Lightsail server, compared to simply checking for the presence of a cookie. The demo website contains a members page using Ultimate Member, where now access to every profile picture has to be validated. It’d be good if this data could be sent in batches, but that would require substantial changes under the hood, so this is it for now.

Conclusion

In securing WordPress files, we’ve addressed a critical vulnerability that exposes media to direct access by unauthorized users. By leveraging server-level configurations, particularly with .htaccess and PHP, we’ve established a robust defense mechanism to prevent unauthorized access to sensitive content.

Through careful implementation of RewriteRules in the .htaccess file, we’ve managed to redirect direct access requests, securing crucial directories like wp-includes and wp-admin/includes. Additionally, by implementing a PHP script for login validation, we’ve ensured that media files are only accessible to authenticated users, mitigating the risk of data scraping and unauthorized access.

However, while these measures significantly enhance security, they may introduce performance overhead, particularly on lightweight server setups. Nonetheless, these trade-offs are necessary to uphold the integrity of WordPress installations and safeguard sensitive data from potential exploitation.