Block suspicious Post View Requests in WordPress

After adding a post view counter to my blog (see “Counting Post Views in WordPress without PlugIn” for details), I noticed some strange view counts on one of the posts. From one day to another, the counter of this specific post jumped from about 200 to more than 12,500. And only some days later, same again, now to about 25,000.

I asked myself what is so special about this post. Is it number one when searching for the topic on Google? But it is not, even not listed on the first result page. There must have been another reason.

Accordingly, I checked the server’s log files and quickly it turned out what the cause was. Someone tried to hack my blog by adding some SQL commands to the view request. As the frequency was about one GET request per second, all sent by the same IP address, it was clear that this was an automated SQL injection attack.

Luckily, WordPress seems to be prepared for such attacks. I did not found any data manipulation or the like. However, for safety reasons, I changed the passwords of the WordPress users.

Nevertheless, I decided to implement a request filter to add an additional defense line. During implementation it turned out that handling suspicious requests for pages is similiar to posts, so I added the handling of page requests too. And a few days later I noticed that also requests for categories, months (archives), and tags can be handled the same way, so I added this too.

Monitoring the results of the security enhancements over the time, I noticed some more aspects to be handled.

Preparation

Before starting to build my own “Suspicious Post View Request Filter”, I had to do some preparations.

Have a local WordPress Installation for Debugging and Testing

Since it isn’t the best idea to do development against a production environment, I first installed WordPress, and all required components to run WordPress on a Windows 10 development machine, locally, using WampServer, following wpbeginner’s tutorial, and managed to bypass the database connection error, which occurred when trying to install WordPress.

Get ready to Debug PHP

As my favorite PHP editor Visual Studio Code does not support PHP debugging out of the box, I installed Felix Becker’s PHP Extension Pack plus Xdebug. To get debugging working, I had an additional look at Marco Rivas’ tutorial “How to debug PHP using Visual Studio Code“. Finally, I think the cause for not getting it running right from the start was the fact that I thought enabling remote debugging is not required, as I run VS Code and PHP on the same machine. I learned that it is required.

Block unwanted Requests

Based on Jeff Star’s post “How to Modify GET and POST Requests with WordPress, I started implementing my own post view request filter.

I am focusing on the filtering of GET requests in here. Please note that Jeff’s approach to filter POST requests based on the remote address only works when the valid remote address can always be mapped to the same domain.

Target Features

My implementation of the blocker should have the following features:

  • Block GET requests on post / page / category / month / tag views that have invalid parameters to avoid e.g. SQL injection
  • Record every blocked request with remote address, invalid query string, and date/time in the database
  • Show a custom page telling the caller that the request was blocked and details are recorded
  • Count the total number of blocked requests per post / page
  • Show the blocked counter per view in the WPAdmin posts / pages overview

Prerequisites

In case you like my approach and want to add it to your WordPress installation, please note that it requires to have the permalink settings set to Plain.

required Permalink Settings

Having this set makes it quite easy to identify a valid view request, as the query string always follows the pattern p=number for posts and page_id=number for pages. By using a regular expression it is very simple to see if something was added to the one and only valid parameter or not.

Probably it is even easier when one of the other common settings is used, which all do not need a parameter at all. But as my setup is using the Plain setting, I follow that. I can imagine that other forms of permalinks just require different regular expressions for filtering, but have not tested it.

Before you change this setting, please note that changing it will break any existing links to your posts, both within your own posts and external. So please think twice before doing so.

For experienced Developers only

As this implementation can cause serious malfunction of your WordPress site, only use it in case you are an experienced developer, having good confidence to handle WordPress changes, understand PHP, and be familiar with SQL databases.

In case you are not 100% sure, please do not copy and implement my approach.

Add Database Table to record blocked Queries

The following script will create a new table which will contain the data of every blocked request. When executing it please make sure you are connected to the WordPress database. In case you ask yourself why the caller_ip is varchar(50): this enables the table to also handle IPV6 addresses.

CREATE TABLE `ifb_blocked_post_queries` (
 `ID` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
 `post_id` bigint(20) unsigned NOT NULL,
 `caller_ip` varchar(50) NOT NULL,
 `query` varchar(1000) NOT NULL,
 `created` datetime NOT NULL,
 PRIMARY KEY (`ID`),
 KEY `post_id` (`post_id`)
) ENGINE=MyISAM CHARSET=your_charset COLLATE=your_collation

Please replace your_charset and your_collation with the appropriate values of your WordPress database instance. You can use the following SQL statement to get the values. Please replace your_databasename in the script below with the name of your database.

SELECT CCSA.* FROM information_schema.`TABLES` T,
       information_schema.`COLLATION_CHARACTER_SET_APPLICABILITY` CCSA
WHERE CCSA.collation_name = T.table_collation
  AND T.table_schema = "your_databasename"
  AND T.table_name = "wp_posts";

Statement found at Stack Overflow

As for my WordPress database tables the engine is MyISAM, I set the same value for the new table for consistency reasons, even though the default engine of the database is different. In case the existing tables of your database use a different engine, please change the script to create the table accordingly.

You can use the following statement to retrieve the engine information per table, by replacing tablename with any of the WordPress table names, e.g. wp_posts:

SHOW TABLE STATUS WHERE Name = 'tablename'

Statement also found at Stack Overflow.

Create Stored Procedure to record blocked Queries

As one of the SQL injection prevention best practices is to use stored procedures, and I am about to store potential risky SQL statements contained in the query, I will use a stored procedure to insert the data into the newly created table. This statement will create the stored procedure:

DELIMITER $
CREATE PROCEDURE `sp_insert_blocked_query`(
    in p_post_id bigint(20),
    in p_caller_ip varchar(50),
    in p_query varchar(1000))
BEGIN
    insert into ifb_blocked_post_queries (post_id, caller_ip, query, created) values (p_post_id, p_caller_ip, p_query, now());
    END$
DELIMITER ;

Again, please make sure you are connected to your WordPress database when executing this statement.

PlugIn to Block the Request

Now that there is the ability to record blocked requests, a new plugin is required to filter out suspicious requests. Details on how to create a plugin can be found at wpbeginner’s “What, Why, and How-To’s of Creating a Site-Specific WordPress Plugin“.

<?php
/*
Plugin Name: Instance Factory request filter functions
Plugin URI: https://instance-factory.com/?p=1706
Description: Contains custom functions to filter GET requests
Author: Instance Factory, a project of the proccelerate GmbH
Author URI: https://proccelerate.de
Version: 2.1
*/

/* Disable direct access to this file */
if (!defined('ABSPATH')) exit;

/* OK, start editing! Happy publishing */

/*
* Functions to block queries on posts having an invalid query string.
*/

/**
 * Filter out GET requests for posts and pages considered to be cyber-attack attempts.
 * 
 * Filters out GET requests based on defined valid query patterns. Returns 403 and a custom page 
 * informing the caller that the request was rejected and details recorded for further analysis.
 * Exits the call in case an invalid request was identified. Also increases the blocked request 
 * counter of the post.
 * 
 * @global wpdb $wpdb The object to access the WordPress database.
 * 
 */
function ifb_filter_get_requests()
{
    if (
        isset($_SERVER['REQUEST_METHOD']) === false
        || strtoupper($_SERVER['REQUEST_METHOD']) !== 'GET'
        || isset($_SERVER['QUERY_STRING']) === false
    ) {
        // request is not relevant
        return;
    }

    // this is a GET request
    $query_string = $_SERVER['QUERY_STRING'];

    // In case the request is not related to posts, pages, categories, months, tags, or search return (avoids the need to create filters e.g. for RSS feed requests).
    if (!ifb_is_query_valid($query_string, "/^p=|^page_id=|^cat=|^m=|^tag=|^s=/", false)) {
        return;
    }

    // It is a GET request for a post, page, category, month, or tag, so validate it
    // As search is disabled, any search request ("^s=") is considered to by invalid. No additional entry in the whitelist is required.
    if (ifb_is_query_valid(
        $query_string,
        "/^p=\d{1,}$"
            . "|^p=\d{1,}&preview=true$" // filter on post id
            . "|^p=\d{1,}&preview_id=\d{1,}&preview_nonce=[0-9a-z]{1,}&preview=true$"
            . "|^page_id=\d{1,}$" // filter on page id
            . "|^page_id=\d{1,}&preview=true$"
            . "|^page_id=\d{1,}&preview_id=\d{1,}&preview_nonce=[0-9a-z]{1,}&preview=true$"
            . "|^cat=\d{1,}$" // filter on category id
            . "|^cat=\d{1,}&paged=\d{1,3}$" 
            . "|^m=20\d{4}$" // filter on month, parameter format is always 20yymm, as this blog started in 20xx
            . "|^m=20\d{4}&paged=\d{1,3}$"
            . "|^tag=[\w,-]{1,}$" // filter on tags, most special characters in tag names are ignored by WordPress
            . "|^tag=[\w,-]{1,}&paged=\d{1,3}$/"
    )) {
        // valid request
        return;
    }

    // invalid request: block and record it
    global $wpdb;

    // Get the id of the post / page
    $post_id = ifb_get_post_id($query_string);

    // prevent SQL-injection when storing the blocked query
    $dbQuery = $wpdb->prepare(
        "call sp_insert_blocked_query(%d, '%s', '%s')",
        array(
            $post_id,
            $_SERVER['REMOTE_ADDR'],
            $query_string
        )
    );

    $wpdb->query($dbQuery);

    if ($post_id !== 0) {
        ifb_set_post_blocks($post_id);
    }

    status_header(403);

    // Create specific output
    get_header();
    echo ("<h1>Invalid Request blocked</h1>");
    echo ("<p>Your request >$query_string< was considered to be invalid and blocked to prevent hacking attacks.</p>");
    echo ("<p>Your IP address, the query, and date/time of access were recorded. ");
    echo ("We reserve the right to provide law enforcement authorities with the information necessary for criminal prosecution in case of a (suspected) cyber-attack.</p>");
    echo ("<p>The recorded data will be deleted if we do not suspect an attempted attack or as soon as the purpose of keeping it no longer applies.</p>");
    echo ("<h3>Do not modify the query strings generated by WordPress to avoid blocking and recording of your requests!</h3>");
    echo ("<p>&nbsp;</p>");
    get_footer();

    exit;
}

/**
 * Verifies if a query string is a valid request based on a pattern passed.
 * 
 * Verification is done using regular expression matching.
 * 
 * @param string $query_string The string to be verified.
 * @param string $pattern The regex pattern to be used for verification
 * @param bool $compare_match Optional. If true, the found regex match have to be equal to the passed query string. Default true.
 * 
 * @return bool true in case the query matches to the pattern, false if not.
 * 
 */
function ifb_is_query_valid(string $query_string, string $pattern, bool $compare_match = true): bool
{
    $matches = array();

    preg_match($pattern, $query_string, $matches);

    if (
        $matches === null
        || count($matches) !== 1
    ) {
        return false;
    }

    if (
        $compare_match
        && strcmp($query_string, $matches[0]) !== 0
    ) {
        return false;
    }

    return true;
}

/**
 * Tries to extract the post / page id from a query string.
 * 
 * Extraction is done using regular expression.
 * 
 * @param string $query_string The string to extract the id from..
 * 
 * @return int The extraced post id or 0 in case the id was not found in the query string.
 * 
 */
function ifb_get_post_id(string $query_string): int
{
    $matches = array();

    preg_match("/^p=\d{1,}|^page_id=\d{1,}/", $query_string, $matches);

    if (
        $matches === null
        || count($matches) === 0
    ) {
        return 0;
    }

    $position = strpos($matches[0], "=");

    // Just in case, should not happen, as the regex requires the occurence of "="
    if ($position === false) {
        return 0;
    }

    // Have to add 1 to start after the "="
    $stringId = substr($matches[0], $position + 1);

    if (!is_numeric($stringId)) {
        return 0;
    }

    return intval($stringId);
}

add_action('parse_request', 'ifb_filter_get_requests', 1, 0);

ifb_filter_get_requests

This is the function that is added to the parse_request hook.

It first checks if the request is a GET request having a query string. If not, it returns immediately. No query string means no SQL injection.

Then, it checks whether the request is related to posts or pages by comparing the query string against the regex /^p=|^page_id=/.

It is worth to know that the parse_request hook is not only called for those requests my plugin is filtering, but also for a variety of different others, e.g. RSS feed. As I am not able to foresee what kind of requests will occur, and intended to filter invalid requests for posts, pages, categories, months (archives), and tags only (and do not see myself to be able to create an entirely complete whitelist), any other request is not handled.

For the defined range of request targets, it verifies that the request matches to the valid patterns of a request. The first is the one passed when a user wants to view the post (^p=\d{1,}$). The next two (^p=\d{1,}&preview=true$ and ^p=\d{1,}&preview_id=\d{1,}&preview_nonce=[0-9a-z]{1,}&preview=true$) I found when testing my plugin while creating / editing a new post. The same patterns are used for page requests, having a page_id instead of p at the beginning. Requests for categories have to match ^cat=\d{1,}$ or ^cat=\d{1,}&paged=\d{1,3}$, those for months ^m=20\d{4}$ or ^m=20\d{4}&paged=\d{1,3}$, and for tags ^tag=[\w,-]{1,}$ or ^tag=[\w,-]{1,}&paged=\d{1,3}$. If you started you blog in 19xx, you need to change the month filter to ^m=d{6}$. The &paged=\d{1,3} part of the cat, m, and tag filters is needed in case more items are found than fit on a page. I do not expect there will ever be 100+ pages, but just in case 😉

The verification itself is done by the function ifb_is_query_valid.

In case the query string matches to one of the patterns, the function returns. The request is considered to be valid.

In case it is considered to be invalid, the id of the requested post / page is extracted from the query string by using the function ifb_get_post_id. The post id will be used to link to the post / page and to increase the blocked requests counter.

As noted by the documentation of the WordPress database class wpdb, “all data in SQL queries must be SQL-escaped before the SQL query is executed to prevent against SQL injection attacks“. Accordingly, this is done before executing sp_insert_blocked_query to save the data.

If the plug in was able to determine the id of the requested post / page, the blocked counter, stored in the wp_postmeta table, is increased by calling the function ifb_set_post_blocks. This can be done for both posts and pages, because from a database perspective posts an pages are the same, means data and metadata is stored in the same tables.

Finally, the HTTP status code is set to Forbidden and the page notifying the user that the request was recorded and rejected is created. To stop the processing of the request by WordPress, the function leaves with an exit statement.

ifb_is_query_valid

This function uses regular expression matching to verify if a query is valid. It is not sufficient that matches are found in the query. There must be exactly one, and the match has to be equal to the query string. Otherwise, queries containing the pattern, but also additional dangerous code, would be considered as valid too. Which must be avoided.

As I do not call myself a regex expert, I added a strcmp to really be sure that the query string only contains the allowed content. Although the ^ at the beginning and the $ at the end of a regex should ensure it.

ifb_get_post_id

This is the helper to extract the id of the potentially attacked post / page from the query string.

Having this id enables me to count the number of blocked requests per post / page. The number of blocked requests per category, month, or tag will not be written to the database.

Extend the Admin’s Post Overview

Like the post view count, I also want to see the number of blocked requests in the admin’s post overview. Here is the code to add this feature. It resides in the same custom-functions.php like the code to filter the requests.

/*
* Functions to add blocked counters to post metadata and display the value in the Admin's Posts overview
*
* Code transferred from counting post views, see https://instance-factory.com/?p=1598
*/

/**
 * Metadata key of the blocked request counter.
 */
const KEY_COUNT_POST_BLOCKED = 'post_blocked_count';
/**
 * Key of the custom column added to the WPAdmin page showing the number of blocked requests per post.
 */
const ADMIN_COLUMN_KEY_POST_BLOCKED = 'post_blocked';

/**
 * Returns the number of blocked requests of a post.
 * 
 * Number is returned as text. Data is retrieved from wp_postmeta table.
 * 
 * @param int $post_id The id of the post.
 * 
 * @return string The number of blocked requests as text.
 * 
 */
function ifb_get_post_blocks(int $post_id): string
{
    $count = get_post_meta($post_id, KEY_COUNT_POST_BLOCKED, true);

    if ($count == '') {
        delete_post_meta($post_id, KEY_COUNT_POST_BLOCKED);
        add_post_meta($post_id, KEY_COUNT_POST_BLOCKED, '0');
        return "0 blocked";
    }

    return $count . ' blocked';
}

/**
 * Sets / increases the number of blocked requests of a post.
 * 
 * Data is stored in the wp_postmeta table. In case no or an empty entry for the metadata is found,
 * the metadata is created with a numeric value.
 * 
 * @param int $post_id The id of the post.
 * 
 */
function ifb_set_post_blocks(int $post_id)
{
    $count = get_post_meta($post_id, KEY_COUNT_POST_BLOCKED, true);

    if ($count == '') {
        delete_post_meta($post_id, KEY_COUNT_POST_BLOCKED);
        add_post_meta($post_id, KEY_COUNT_POST_BLOCKED, '1');
    } else {
        $count++;
        update_post_meta($post_id, KEY_COUNT_POST_BLOCKED, $count);
    }
}

/**
 * Adds a column to the WPAdmin posts view to show the number of blocked requests.
 * 
 * Also defines the width of the column.
 * 
 * @param array $columns The list of the columns of the view.
 * 
 * @return array The extended column list.
 * 
 */
function ifb_add_posts_column_blocked(array $columns): array
{
    // Simple, and bit dirty, way to adjust column width; https://wordpress.stackexchange.com/questions/33885/style-custom-columns-in-admin-panels-especially-to-adjust-column-cell-widths
    $columns[ADMIN_COLUMN_KEY_POST_BLOCKED] = 'Blocked<style>.column-post_blocked { width: 5em; }</style>';

    return $columns;
}

/**
 * Shows the number of blocked requests of a post.
 * 
 * @param string $column_name The name of the column for which the data is to be shown.
 * @param int $post_id The id of the post for which the data is to be shown..
 * 
 */
function ifb_show_posts_custom_column_blocked(string $column_name, int $post_id)
{
    if ($column_name === ADMIN_COLUMN_KEY_POST_BLOCKED) {
        echo ifb_get_post_blocks($post_id);
    }
}

// Add the column to both posts and pages overview
add_filter('manage_posts_columns', 'ifb_add_posts_column_blocked');
add_action('manage_posts_custom_column', 'ifb_show_posts_custom_column_blocked', 10, 2);
add_filter('manage_pages_columns', 'ifb_add_posts_column_blocked');
add_action('manage_pages_custom_column', 'ifb_show_posts_custom_column_blocked', 10, 2);
 

/* That’s all, stop editing! Happy publishing */

ifb_get_post_blocks retrieves the counter of the passed post id from wp_postmeta. ifb_set_post_blocks sets / increments the value. Because posts and pages and their metadata reside in the same tables (wp_posts and wp_postmeta), there is no need to distinguish between posts and pages. The id passed is the unique primary key of wp_posts.

ifb_add_posts_column_blocked adds a new column to the admin’s posts / pages overview to show the counter per post / page. I am using this kind of dirty way to adjust the column width, as changing the width via custom CSS or styles.css did not worked for whatever reason.

ifb_show_posts_custom_column_blocked returns the data to be displayed.

const variables are used to avoid typos.

You will notices that ifb_add_posts_column_blocked and ifb_show_posts_custom_column_blocked are not only added to the post-related filter and action, but also to the page-related. As said, posts and pages are kind of “same”, so only two more lines add the blocked counter to the pages overview :-).

WPAdmin Pages Overview

Changes to Post View Counter Functions

Having these things in place and the plugin activated in my test instance, I noticed that the blocked counter was listed before the views counter in the posts overview page. Since for me, the number of views is more relevant than the number of blocked requests, I wanted to change the order of the columns.

Because the views counter column was added after the blocked counter column, I had to change the function that adds the view counter column. Stack Overflow gave me the hint on how to do this.

This is the changed code:

/**
 * Key of the custom column added to the WPAdmin page showing the number of post views.
 */
const ADMIN_COLUMN_KEY_POST_VIEWS = 'post_views';

/**
 * Adds a column to the WPAdmin posts view to show the number of views per post.
 * 
 * Ensures that the added column will be added before the blocked counter. Also defines the width of the column.
 * 
 * @param array $columns The list of the columns of the view.
 * 
 * @return array The extended column list.
 * 
 */
function ifb_add_posts_column_views($columns): array {
    $column_added = false;

    // Simple, and bit dirty, way to adjust column width; https://wordpress.stackexchange.com/questions/33885/style-custom-columns-in-admin-panels-especially-to-adjust-column-cell-widths
    $column_description = 'Views<style>.column-post_views { width: 5em; }</style>';

    $new_column_list = array();

    foreach($columns as $key => $title){
        // Add the column before the number of blocked requests https://wordpress.stackexchange.com/questions/8427/change-order-of-custom-columns-for-edit-panels
        if ($key == ADMIN_COLUMN_KEY_POST_BLOCKED){
            $new_column_list[ADMIN_COLUMN_KEY_POST_VIEWS] = $column_description;
            $column_added = true;
        }
        $new_column_list[$key] = $title;
    }

    // Make sure the column will be added, even in case there is no 'blocked' column.
    if (!$column_added){
        $new_column_list[ADMIN_COLUMN_KEY_POST_VIEWS] = $column_description;
    }
    
    return $new_column_list;
}

The rest of the view counting code is described by my post “Counting Post Views in WordPress without PlugIn“.

Now the posts view looks like this:

WPAdmin Posts Overview

See Filtering in Action

In case you like to see the what happens when you enter an invalid query, just click on this link: https://instance-factory.com/?p=1706-. It will open a new browser tab / window, return a 403 (Forbidden) and shows the error page. Keep in mind that also these tests will be written to the database (but will be automatically deleted daily without further action).

The reason for rejecting this request is the - at the end of the URI.

Disable WordPress Search

Monitoring the daily traffic of my blog, I noticed that someone tried to run a code injection attack using WordPress’ search facility. Even Wordfence did not blocked this attempt (I sent the request to Wordfence as a new sample, maybe they are able to extend their tool). Because I did not saw any other search requests, I decided to disable the search facility.

Having my own theme already implemented, I just had to add some code to the theme’s functions.php file and override the 404.php of the parent theme, as it contains the suggestion to use the search facility in case a page was not found.

The source code I found at wpbeginner was not fully working. It was not hiding the search form. Also, some PHP warnings popped up when I ran it. I fixed these issues and added the following code to my theme’s functions.php:

/*
* Start of disabling Search Functionality, in case the request blocker is not active
*/

function ifb_filter_query($query)
{
    if (is_search()) {
        $query->is_search = false;
        $query->query_vars['s'] = false;
        $query->query['s'] = false;
    }
}
add_action('parse_query', 'ifb_filter_query', 1, 1);

add_filter('get_search_form', function ($a) {
    return '';
});

function ifb_remove_search_widget()
{
    unregister_widget('WP_Widget_Search');
}
add_action('widgets_init', 'ifb_remove_search_widget');

/*
* End of disabling Search Functionality
*/

This code snippet can also be found on GitHub.

Blocking Search Requests

Additionally, I enhanced the blocking filter to also block any search request. This is in case the request itself was not sent by the WordPress UI, but e.g. by a bot. You can find the updated filter in the function ifb_filter_get_requests shown above.

As any search request should be blocked, there is no need to extend the whitelist of the filter plugin.

I think it is worth to note that the new filter also blocks search requests sent from the wp-admin/edit.php form. Interestingly, this page does not use the search form generated by get_search_form, but its own.

Require Authentication for REST API Access

Another thing I noticed when looking at the daily traffic was the fact that someone was using the WordPress REST API to read post content. Searching a little bit I learned that by default, the entire REST API of WordPress can be used without authentication. Nothing I like! So I searched for a way on how to prevent this.

The WordPress REST API Handbook contains code to block any REST API calls without authentication. Based on that code, I created another plugin which blocks any unauthenticated WordPress REST API calls.

The handbook also points out that the REST API should not be disabled, as this will break the admin functionality. Forcing authentication I think is a good way to go.

Here is the code I copied from the handbook and added to my plugin (plugin header omitted):

add_filter('rest_authentication_errors', function ($result) {
    // If a previous authentication check was applied,
    // pass that result along without modification.
    if (true === $result || is_wp_error($result)) {
        return $result;
    }

    // No authentication has been performed yet.
    // Return an error if user is not logged in.
    if (!is_user_logged_in()) {
        return new WP_Error(
            'rest_not_logged_in',
            __('Accessing the REST API requires authentication. You are not currently logged in.'),
            array('status' => 401)
        );
    }

    // Our custom authentication check should have no effect
    // on logged-in requests
    return $result;
});

Facebook’s Click Identifier Parameter blocks Requests

Some time ago, Facebook started to add a click identifier parameter to links shared at Facebook. The parameter is fbclid. Having my filter implemented, any visits from Facebook containing this parameter were blocked, as it is not known as a WordPress parameter. Instead of extending the whitelist, I followed the suggestion found in the Internet, like here at Stack Overflow. The solution mentioned there is to re-route the call and remove this parameter. To do so, I had to add some lines of code to my .htaccess file. I put it in front of any other content.

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{QUERY_STRING} ^(.*&|)fbclid=.*?(?:&(.*)|)$
RewriteRule (.*) /$1?%1%2 [R=301,L]
</IfModule>

Disable XML-RPC

From monitoring the calls to my blog I learned there is a WordPress feature called XML-RPC.

Kevin Wood’s post “What Is Xmlrpc.php in WordPress and Why You Should Disable It” gave me a better understanding of what it is (to me, an obsolete feature to do offline blogging which should be disabled by default, at least should have the opportunity to disable it via the UI) and how it can be disabled.

As I do not like to add more plugins to my WordPress installation than what is absolutely required (including those written by me), I was choosing the manual disable approach. This is done by adding a few lines to the .htaccess file to block access to xmlrpc.php. Not needing to allow any access at all, I was able to shorten Kevin’s code sample to the following lines:

# Block WordPress xmlrpc.php Requests
<Files xmlrpc.php>
deny from all
</Files>

Gravity Forms Plugin Upload Vulnerability

Being a little bit interested in security topics, one can often read that hackers like to use well known, and already fixed, vulnerable.

An attack attempt tried to exploit a vulnerable of the WordPress plugin Gravity Forms before version 1.8.20.5. Details are described by Sucuri.

Not having this plugin installed, there was no need for action.

Conclusion

Is it perfect? Far away from that! E.g. it only covers a subset of all potential GET requests, and no POST requests at all. But, I think it is better than nothing. It keeps away at least some of the cyber-attack attempts from my blog, and gives me some insights on what is going on.

Is it complete? Well, for what it is intended to do, I think so 😉 But I have not tested all WordPress features, and I can’t tell which plugin or theme might struggle with this filter. Using a whitelist is always very restrictive. But having the filtering limited to posts, pages, categories, months, and tags only, I hope there will be no conflicts.

In case you like this functionality, added it to your WordPress installation, and something is redirected to the error page that should not, check what the request and query string is, build a matching regex expression, and add it to the filter function.

License, (no) Warranty

Please note that you use the code at your own risk.

The code presented here is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version.

The code is distributed in the hope that it will be useful, but without any warranty; without even the implied warranty of merchantability or fitness for a particular purpose. See the GNU General Public License for more details.

Alternatives

I installed the WordPress plugin “Injection Guard” by Fahad Mahmood. Maybe I should have spent some more time on how to use it. Finally, it was not obvious to me how it works, so I decided not to use it.

Another, much more popular one, is “Wordfence Security – Firewall & Malware Scan” by Wordfence. It has tons of options. After finishing the learning phase, I see some blocked requests that my filter blocked too, plus, of course, few attempts that my filter is not looking for. Overall I would say, it is worth to have it installed. But: Still you need to understand some basics to be able to configure it properly. In my case for example, it added a few calls to the whitelist during the learning phase that were hacking attempts. And it takes some time to become familiar with it.

Links

Bypass database connection error on Windows 10 using WampServer
Counting Post Views in WordPress without PlugIn
Documentation of WordPress database class wpdb
Felix Becker’s PHP Extension Pack for Visual Studio Code
GNU General Public License
Jeff Star’s post “How to Modify GET and POST Requests with WordPress
Marco Rivas’ tutorial “How to debug PHP using Visual Studio Code
PHP extension Xdebug for PHP debugging and profiling
Stack Overflow “Change Order of Columns“in WordPress
Stack Overflow discussion “What is fbclid? the new facebook parameter
Stack Overflow “How to query character set of MySQL database
Stack Overflow “How to query engine of MySQL table
Sucuri’s description of “Malware Cleanup to Arbitrary File Upload in Gravity Forms
Test link: https://instance-factory.com/?p=1706-
Visual Studio Code
WampServer Download on SourceForge
WordPress plugin “Injection Guard” by Fahad Mahmood
WordPress plugin “Wordfence Security – Firewall & Malware Scan” by Wordfence
WordPress REST API Handbook “Require Authentication for all REST API Requests
WordPress XML-RPC
wpbeginner’s “How to Disable the Search Feature in WordPress
wpbeginner’s tutorial to install WordPress on a Windows 10 machine
wpbeginner’s tutorial “What, Why, and How-To’s of Creating a Site-Specific WordPress Plugin