NGINX / Security / Server Setup

Best practice secure WordPress NGINX configuration for Plesk

by ,


We have by far the largest RPM repository with dynamic stable NGINX modules and VMODs for Varnish 4.1 and 6.0 LTS. If you want to install NGINX, Varnish, and lots of useful modules for them, this is your one-stop repository to get all performance-related software.
You have to maintain an active subscription in order to be able to use the repository!

WordPress Security

WordPress is the most popular CMS for running a website. As such, it is the target for all kinds of malicious bots.

The most effective way of reducing security risks associated with WordPress is the proper server configuration.

Secure WordPress NGINX configuration must be built on the assumption that any unknown script is malicious.
User-uploaded scripts should not be allowed for execution as their execution is #1 of a defaced website.

Thus our configuration example is built upon a whitelist approach.

While we’re at it, we also eliminate the negative effects of try_files and improve performance overall.

Pre-requisites

WordPress settings

Pretty Permalinks in WordPress must be enabled.

NGINX modules

There are some NGINX modules required for achieving higher security.

For CentOS/RHEL servers with the Plesk control panel

Users of the latest Plesk can install the required modules via:

sudo yum -y install https://extras.getpagespeed.com/release-latest.rpm
sudo yum -y install sw-nginx-module-length-hiding sw-nginx-module-immutable sw-nginx-module-security-headers

And enable via these commands:

plesk sbin nginx_modules_ctl --enable length-hiding
plesk sbin nginx_modules_ctl --enable immutable
plesk sbin nginx_modules_ctl --enable security-headers

Alternatively, these modules can be installed from source, which is, however, only advisable for testing, on a development system.

Our secure NGINX configuration relies on a typical structure and files naming conventions.
As we go ahead with creating and modifying, we’ll review their purpose.

Subsequent configuration can be done solely in the Plesk admin interface.

Step 1. Enable NGINX+PHP-FPM for your domain

In Plesk, go to Domains > example.com.
Under “Hosting Settings”, specify “FPM application served by nginx”

Click Apply.

Step 2. Switch on NGINX-only hosting for your domain

In Plesk, go to Domains > example.com > Apache & nginx Settings.
In the nginx settings section, disable the Proxy mode setting.

Enable NGINX-only hosting for a site in Plesk
NGINX-only hosting for a site in Plesk

Click Apply.

Step 3.

Now on the same screen “Apache & nginx Settings”, we will paste in the secure NGINX configuration, .

Under “Additional nginx directives”, put the following, while making sure to replace example.com with the domain of your website.
Do not include www. prefix in your domain name.

# do not load WordPress when redirecting /index.php to /
location = /index.php {
    return 301 /;
}

# do not load WordPress when redirecting /wp-admin to to /wp-admin/
location = /wp-admin {
    return 301 /wp-admin/;
}

location / {

    # any URI without extension is routed through PHP-FPM (WordPress controller)
    location ~ ^[^.]*$ {
        length_hiding on;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
        fastcgi_pass "unix:///var/www/vhosts/system/example.com/php-fpm.sock";
        include /etc/nginx/fastcgi_params;
    }

    # allow only a handful of PHP files in root directory to be interpreted
    # wp-cron.php ommited on purpose as it should *not* be web accessible, see proper setup
    # https://www.getpagespeed.com/web-apps/wordpress/wordpress-cron-optimization
    location ~ ^/wp-(?:comments-post|links-opml|login|mail|signup|trackback)\.php$ {
        length_hiding on;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass "unix:///var/www/vhosts/system/example.com/php-fpm.sock";
        include /etc/nginx/fastcgi_params;
    }

    # other PHP files "do not exist"
    location ~ \.php$ {
        return 404;
    }
}

location = /xmlrpc.php {
    # allows JetPack servers only
    allow 192.0.0.0/16;
    deny all;

    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_pass "unix:///var/www/vhosts/system/example.com/php-fpm.sock";
    include /etc/nginx/fastcgi_params;
}

location /wp-admin/ {
    index index.html index.php;

    location = /wp-admin/admin-ajax.php {
        # this location often spits json, which will be broken if length hiding is used
        # so no length hiding here
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass "unix:///var/www/vhosts/system/example.com/php-fpm.sock";
        include /etc/nginx/fastcgi_params;
    }

    # numerous files under wp-admin are allowed to be interpreted
    # no fancy filenames allowed (lowercase with hyphens are OK)
    # only /wp-admin/foo.php or /wp-admin/{network,user}/foo.php allowed
    location ~ ^/wp-admin/(?:network/|user/)?[\w-]+\.php$ {
        length_hiding on;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass "unix:///var/www/vhosts/system/example.com/php-fpm.sock";
        include /etc/nginx/fastcgi_params;
    }

}

location /wp-content/ {
    # contents under wp-content are typically highly cacheable
    immutable on;
    # hide and do not interpret internal plugin or user uploaded scripts
    location ~ \.php$ {
        return 404;
    }
}

# hide any hidden files
location ~ /\. {
    deny all;
}

# hide any backup or SQL dump files
location ~ ^.+\.(sql|bak|php~|php#|php.save|php.swp|php.swo)$ {
    return 404;
}

Click Apply. That’s all there is to it. WordPress is faster and more secure.

Verification

  • Access a known PHP script that is not meant for web browser access, e.g. example.com/wp-blog-header.php. On successful configuration, you will get a 404 (Not Found) error.
  • Pretty permalink URLs are fully functional
  • Open HTML source of any page, and locate random-length HTML comment. It should be present.

How it works

Let’s review all the pieces of our secure WordPress configuration.

The security_headers on; enable sending security headers via ngx_security_headers module. This one-liner configuration allows sending them “the right away”, as in while required only and according to their specification

Next up, we created a couple of locations:

    # do not load WordPress when redirecting /index.php to / 
    location = /index.php {
        return 301 /;
    }

    # do not load WordPress when redirecting /wp-admin to to /wp-admin/
    location = /wp-admin {
        return 301 /wp-admin/;
    }

The comments are self-explanatory. When WordPress sees URIs /index.php, it redirects to the base URL of your website.
It helps with SEO content duplication reduction.
Having those redirects in NGINX allows reducing CPU use by avoiding a load of heavy WordPress stack for the simple task of redirecting.

Next up, you’ll note the use of nested locations wherever possible. This is as per NGINX author’s recommendation.
Nested locations allow isolating the use of regular expressions and an NGINX configuration that can scale well, in terms of maintenance.

Our key location which handles SEO-friendly URLs builds on a simple assumption that they do not include dots, which typically indicates an actual file with an extension.
Thus we default to serving URIs without dots through the WordPress’ front-controller file, /index.php:

        location ~ ^[^.]*$ {
            length_hiding on;
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
            ...
        }

The length_hiding on; enables insertion of randomly generated HTML comment:

<!-- random-length HTML comment: JnSLGWeWYWsoJ4dXS3ubLw3YOu3zfGTotlzx7UJUo26xuXICQ2cbpVy1Dprgv8Icj6QfOZx2Ptp9HxCVoevTxhKzMzV6xeYXao0oCngRWJRb4Tvive1iBAXLzrHlLg6jKwNKXrct0tJuA2TvWIRVIng6UoffIbCQLPbi63PwmWemOxVi6m3CPa6hCbAK2CaBR1jLux7UJa4WNN4H0yIDMElMglWWouY5m5FUqAn0afMmtErj0zkA2LMWxisZRES38XLoYycySmaBrIih5IixUsJFR0ei4uZ0IifgV5SnitoNzMusSQem9npObHuU2HKApneAjwnFdPSQZA9sRdSOE8agDI05P832mV1JIcOjsg0FgzxvSG7UEX0HdqBqp2jPOYYW0k5gGtmkiXWydRJfn9lGomxReUeqq2Aec69gplEM6a8aqH5TFgXrGK8jcaPISQlsKkMxJQ7Fp6fVDbmI59xCIvlk --> 

This is a measure that eliminates the possibility of a BREACH attack.

Next, we specify which .php files are allowed for execution, other than index.php:

        # allow only a handful of PHP files in root directory to be interpreted
        # wp-cron.php ommited on purpose, see proper setup
        location ~ ^/wp-(?:links-opml|login|mail|signup|trackback)\.php$ {
            length_hiding on;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            ...
        }

        # other PHP files "do not exist"
        location ~ \.php$ {
            return 404;
        }

The secure configuration only allows a few well-known PHP files to be executed.
Unlisted PHP are silently discarded with HTTP status code 404, thus preserving information disclosure wherever possible.

The /wp-content/ directory is the primary source of user-uploaded images, plugins, and their scripts.

A well-coded plugin never needs to be executed by its direct URI under /wp-content/<handle>/foo.php.
If you find that such a plugin is present in your WordPress instance, it is a candidate for removal, and in very exceptional cases, whitelisting in NGINX configuration.

A perfect WordPress website allows no execution of PHP scripts under /wp-content/ directory.

The publicly accessible files in it are highly cacheable (images). The immutable on; enables sending caching headers with Far Future Expiration, as well as the immutable attribute.
This ensures the highest browser cachability for your WordPress while reducing network usage on both the server and client-side.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: