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.
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.
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.
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:
.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.
.htaccess
worksConnect 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.
<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
<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
.
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
:
# 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.
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:
{
"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:
# 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:
<?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);
}
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.
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.