fbpx

NGINX / Security

Best practice secure NGINX configuration for WordPress

by , , revisited on


We have by far the largest RPM repository with NGINX module packages and VMODs for Varnish. If you want to install NGINX, Varnish, and lots of useful performance/security software with smooth yum upgrades for production use, this is the repository for you.
Active subscription is required.

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 without control panels

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

Enable the modules in /etc/nginx/nginx.conf by placing at the top of the file:

load_module modules/ngx_http_length_hiding_filter_module.so;
load_module modules/ngx_http_immutable_module.so;
load_module modules/ngx_http_security_headers_module.so;

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 file naming conventions.
As we go ahead with creating and modifying, we’ll review their purpose.

/etc/nginx/includes/php-example.com.conf

Create directory /etc/nginx/includes if it doesn’t exist. The file php-example.com.conf serves as a connecting point between NGINX and PHP-FPM.
In there, we specify the path to the UNIX socket file, which should match your PHP-FPM pool configuration.

        fastcgi_pass unix:/var/run/php-fpm/example.com.sock;
        include fastcgi_params;

/etc/nginx/sites-available/example.com.conf

NGINX configuration is typically organized in a way that website-specific is stored in its own configuration file under directory /etc/nginx/sites-available.
Let’s create a secure configuration for the website example.com.
We’re not touching upon TLS configuration, as we want to concentrate on WordPress security requirements:

server {

    server_name example.com;

    root /srv/www/example.com/public;

    security_headers on;

    # 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;
            include includes/php-example.com.conf;
        }

        # 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;
            include includes/php-example.com.conf;
        }

        location ^~ /wp-json/ {
            fastcgi_param SCRIPT_FILENAME $document_root/index.php;
            include includes/php-example.com.conf;
        }

        # 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;
        include includes/php-example.com.conf;
    }


    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;
            include includes/php-example.com.conf;
        }

        # 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;
            include includes/php-example.com.conf;
        }

    }

    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;
    }
}

After editing your configuration, don’ forget to reload NGINX, e.g.:

systemctl reload nginx

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 the 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;
            include includes/php-example.com.conf;
        }

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;
            include includes/php-example.com.conf;
        }

        # 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.

Once you know you have no bad players (plugins) in your /wp-content, you can apply the honeypot approach to it.
Add all the necessary configuration from the linked article, then update the location ~ \.php$ { in a way that instead of returning 404, it will include the honeypot configuration.
This will make any bad bots trying to access plugins’s PHP files under wp-content immediately blocked:

    # other PHP files cause automatic ban:
    location ~ \.php$ {
        include includes/honeypot.conf;
    }

The publicly accessible files in wp-content 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.

  1. Dragos

    I liked you comment on that gist saying it’s a guide to WordPress INsecurity as it was whitelisting too many links. 🙂

    I was wondering if wp-comments-post.php should also be included in the list of to be interpreted PHP files in the WP root directory?

    Thanks for putting this together.

    Reply
  2. oetti

    Really helpful post. Keep up the good work!

    Thanks!

    Reply
  3. Hans

    Great post, thanks so much!
    One addition: The youtube embedding of wordpress didn’t work for me after using your rules.
    In my nginx logs I found entries like: “[…] GET /wp-json/oembed/1.0/proxy?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3SOME_VIDE_LINK HTTP/2.0″ 404 […]”
    I added this sublocation under “location /” to circumvent the 404 errors:

    location /wp-json/ {
                    fastcgi_param SCRIPT_FILENAME $document_root/index.php;
                    include common/php-example.com.conf;
                }
    

    to also allow these wp-json queries – but since I’m not an expert I hope this is OK or maybe there is a better way?

    Reply
    • Danila Vershinin

      Thank you for bringing that up. Indeed, /wp-json/ should be routed through PHP-FPM. Updated the snippet. I think it is best to use. non-regex priority matching, thus location ^~ /wp-json/ { but simple prefixed location like you did will work as well.

      Reply
      • Hans

        Thanks for your fast reply! I also updated my nginx config as your proposal “^~” matching.

        But as soon as I change

        fastcgi_param SCRIPT_FILENAME $document_root/index.php;

        to

        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        the youtube previews are not shown anymore… To be honest I don’t know why, can you explain me this detail or help me here?

        Reply
        • Danila Vershinin

          Pardon, it should really be fastcgi_param SCRIPT_FILENAME $document_root/index.php; Thanks for noticing.

          Reply
  4. Karen

    Danila,

    Is there an Nginx security config that would replace the code below and still allow Ajax / Post etc? While the code below blocks access to the dashboard I’m having little issues; like users cannot save changes to Mailpoet subscription settings when it’s enabled.

    //Blocks admin access
    add_action( 'init', 'blockusers_init' );
    function blockusers_init() {
       if ( is_admin() && ! current_user_can( 'administrator' ) && ! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
          wp_redirect( home_url() );
          exit;
       }
    }
    

    Thoughts?

    Thanks

    Karen

    Reply
    • Danila Vershinin

      NGINX does not have a way to know if a request comes from an administrator. Thus in NGINX you can do conditional blocking based on other data, like IP addresses.

      You can do this in this block:

              location ~ ^/wp-admin/(?:network/|user/)?[\w-]+\.php$ {
                  length_hiding on;
                  fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
                  include includes/php-example.com.conf;
      
                  # whitelist IPs here:
                  allow 1.2.3.4;
                  allow 2.3.4.5;
                  deny all;
              }
      

      In this way PHP requests to /wp-admin/ from untrusted IP addresses will result in 403/Forbidden.
      It may be not as elegant as redirecting to homepage, but it is better in terms of performance: any bots hitting /wp-admin/ will not cause any CPU load incurred from PHP.

      Reply
  5. Mike

    With this configuration, WordPress gives this warning and in turn, cannot run WP scheduler:

    Your site could not complete a loopback request. The loopback request returned an unexpected http status code, 404, it was not possible to determine if this will prevent features from working as expected.

    I had to pass requests to php-fpm instead of returning 404 in the block:

    location ~ \.php$ {
        return 404;
    }
    

    But, is there a more secure way to solve that problem?

    Mike

    Reply
    • Danila Vershinin

      The WP Site Health checks loopback using /wp-cron.php endpoint.
      The other loopback requests are functional but are not being checked.

      In a good setup, /wp-cron.php shouldn’t be web-accessible. This is why it is not listed in the above configuration as something allowed, and there’s a comment about it.
      There is a complete article on how to set up WordPress cron in an efficient manner.
      When you set it up as a real Linux cron, it will not require a loopback request, which is subject to timeouts and other issues.

      It would be better if WordPress used different URLs for this check, in order to avoid false positive error message when cron is set up via Linux cron. Filed a Trac ticket about it.

      Reply
  6. Thomas

    Thanks for the great article with so much valuable insights.
    I just stumbled over the regex in the wp-admin location:

    ^/wp-admin/(?:network/|user/)?[\w-]+.php$

    The whole

    (?:network/|user/)?

    part seems to be useless to me, since

    [\w-]+

    allows [a-z,A-Z,0-9,-] as often as you like… so with this you more or less allow every of these characters as often as you wish and upper and lowercase.
    Here is a little test for this (you have to escape the slashes there in the regex):
    https://regexr.com/5stn2

    I don’t know if this is ideal – maybe one can be more strict here?
    Thanks for the great work!

    Thomas

    Reply
    • Danila Vershinin

      The (?:network/|user/)? is actually not useless, as it specifies to allow files from either /wp-admin/foo.php, /wp-admin/network/foo.php, or /wp-admin/user/foo.php,
      not from /wp-admin/bar/foo.php. See this test, which I like better as it allows to specify non-slash delimiter like ‘@’ easily, and thus no need escaping slashes.

      If you want to go strict about this particular regex further, you surely can, although maybe a bit of over-thinking security (if such is possible).
      https://regex101.com/r/3Fenae/1

      The PHP filenames in wp-admin currently only have underscores and dashes, and only some files end with a digit, so their basename matches ^[a-z-_]+\d?\.[a-z]+$.

      You can verify with find /path/to/wp-admin -name '*.php' -type f -printf "%f\n" | grep -v --perl-regexp '^[a-z-_]+\d?\.[a-z]+$' which prints any filenames that don’t match the pattern.

      So you can use this for wp-admin which would be more strict: ^/wp-admin/(?:network/|user/)?[a-z-_]+\d?\.[a-z]+$ (test)

      Reply
      • Thomas

        Hello,
        thank you for your very detailed answer! you are absolutely right, I missed the “/” after “network” and “user” in my testing setup – sorry for harassing you with this.
        Thanks for taking you the time to answer! This is a valuable blog for me to learn.

        Greetings

        Thomas

        Reply
  7. Paav

    Seems very good but I will change fastcgi pass to IP address to avoid Linux sock ex. 127.0.0.1:9000

    link removed by admin

    Reply
    • Danila Vershinin

      Using IP network socket (IP+port) on a single server setup makes absolutely no sense. It’s like taking a taxi to another entrance of the same building you live in.

      UNIX sockets are the high performance alternative for when everything is on the same server. Use IP address/port only when your PHP-FPM is on a separate server from NGINX.

      UNIX sockets are widely supported by other software as well, so you can even build a UNIX-socket only setup which will be further faster.

      There are many other “bad’ things in the linked script, so I’d rather clean up and remove the reference to it.

      Reply
  8. iridude

    XMLRPC can also just use fail2ban just saying

    Reply
  9. iridude

    Note instead of using honeypot would be easier to use fail2ban with regex.

    Reply
  10. fairyfoxhujiujiu

    when first operating , it shows

    To enable package installs, subscribe using the following link:

    https://www.getpagespeed.com/repo-subscribe/4X.6X.8X.5X

    then typing the url in browser , it shows the paypal payment page like this

    Oh god , all right , I can just show it by ‘http://144.202.26.73/’ ……… Not a phishing link or a scam link OK ????? just have a look for no image can be posted as commet

    although I’m not so poor to buy one subscribe ……… but please directly past the price index under this article ok ?????? Oh god , after long time planning and exciting and has been thinking “Wow , my servers’ life saved” , finally still killed by the subscribe cost ………..

    God bless you kind man ………

    Reply
    • Danila Vershinin

      Hello. Actually nearly every article page in this blog has a topmost mention “Active subscription is required”. I do admit that clear pricing should be added. However it wasn’t added because various operating systems have a free tier level. If you want things for free, use Fedora Linux, GetPageSpeed packages do not require payment for those. And please check the FAQ why things are not free anymore. If people have spent the time on supporting the project rather than just leaching on free IT infrastructure maybe things would be better for entire community. If you aren’t happy with the pricing I welcome use to use NGINX plus that costs 2500 per year and good luck finding immutable or security headers there. Thank for taking the time to comment here and have a wonderful day ahead of you.

      Reply
  11. JustFree

    Not correctly works with following URI:

    “GET /wp-json/wp-site-health/v1/tests/page-cache?_locale=user HTTP/2.0” 401
    “GET /wp-json/wp-site-health/v1/tests/background-updates?_locale=user HTTP/2.0” 401
    “GET /wp-admin/site-health.php HTTP/2.0” 302
    “GET /wp-json/wp-site-health/v1/tests/authorization-header?_locale=user HTTP/2.0” 401

    Reply
    • Danila Vershinin

      The 401 stands for Unauthorized which means you have protected wp-json with some credentials, so it works in whichever way it was configured.

      Reply
      • Justfree

        Not all of them return 401, for example:
        “GET /wp-admin/site-health.php HTTP/2.0” 302

        It looks like some check’s don’t works correctly. For example loopback requests.

        Reply
        • Danila Vershinin

          As far as 302 there’s another story in your customizations. Nothing in the article uses 302. Only 301.

          Reply
          • JustFree

            You are right. I was check and found issue with error_page. But.. found another one with site-health.php and REST API. As I can understand this script call index.php route, for example:

            “GET /index.php?rest_route=%2Fwp%2Fv2%2Ftypes%2Fpost&context=edit HTTP/1.1” 301 162 “-” “WordPress/6.2;
            GET /index.php?rest_route=/wp/v2/blocks&context=edit&per_page=100&_locale=user HTTP/2.0” 301

            etc…

            I suppose, we should fix location with index.php.

          • Danila Vershinin

            So far none of the “issues” you have been in any way related to the article’s config and not your own…

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

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