nginx logo

When testing changes to your Nginx configuration for your staging or production (or any other remote) environments, one can encounter problems:

  • If the changes are made as part of a CI/CD pipeline, each change requires you to wait for the pipeline to build before testing. This waiting time will typically be minutes rather than seconds
  • Making the changes directly to the configuration files on the server means you can test your changes basically straight away. However,
    • If you have a load balancer directing traffic to more than one instance, you have to make the same change in several places
    • If you have a load balancer and your instances are in a private subnet, SSH-ing into those instances requires setting up a VPN
    • Changing files directly on a server is bad practice as there is no record of those changes. What happens if you would like to revert a mistake?

One way around this is to test your Nginx configuration changes locally (like you would with regular application code).

In this post we will show how to do this.

Prerequisites

  • Docker; whilst you could download Nginx directly onto your machine, using Docker means you can easily:
    • Install and uninstall Nginx
    • Reset your Nginx configuration files
    • Run multiple Nginx processes at the same time
    • Change the port Nginx runs on
    • Change Nginx version…, etc.

Getting started

To run Nginx in a Docker container and serve requests on port 8000 on your machine,

docker run --interactive --tty --publish 8000:80 nginx bash

(if you are on macOS, you might have to start the Docker daemon first by clicking on an icon)

This command also SSHs you into the container.

In the container shell, check Nginx is installed

which nginx

/usr/sbin/nginx

and that it is running

service nginx status

[FAIL] nginx is not running ... failed!

As it is not running, start it

service nginx start; service nginx status

[ ok ] nginx is running.

Make a request to http://localhost:8000 in the browser. You should see the "Welcome to nginx!" page.

Similarly, in Python

import requests

r = requests.get('http://localhost:8000')
r.status_code
r.text

you should get back the HTML of the page and the response status code.

You should also see the relevant logs in the container shell

172.17.0.1 - - [21/Oct/2019:17:53:21 +0000] "GET /favicon.ico HTTP/1.1" 404 555 "http://localhost:8000/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36" "-"

172.17.0.1 - - [21/Oct/2019:17:54:51 +0000] "GET / HTTP/1.1" 200 612 "-" "python-requests/2.18.4" "-"

Changing the “Welcome to nginx” page

To make changes to the Nginx configuration, we will need to edit files inside the container shell.

By default, there is no text editor inside the container shell.

We will install Vim, but any text editor will do.

Inside the container shell,

  • apt-get update
  • apt-get install vim --yes

We can now use our text editor to have a look at the Nginx configuration

  • cd /etc/nginx/conf.d
  • vi default.conf

You should see the following configuration or similar

server {
    listen       80;
    server_name  localhost;

    #charset koi8-r;
    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }

    #error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }

    # proxy the PHP scripts to Apache listening on 127.0.0.1:80
    #
    #location ~ \.php$ {
    #    proxy_pass   http://127.0.0.1;
    #}

    # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
    #
    #location ~ \.php$ {
    #    root           html;
    #    fastcgi_pass   127.0.0.1:9000;
    #    fastcgi_index  index.php;
    #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
    #    include        fastcgi_params;
    #}

    # deny access to .htaccess files, if Apache's document root
    # concurs with nginx's one
    #
    #location ~ /\.ht {
    #    deny  all;
    #}
}

As the web server document root is /usr/share/nginx/html, inside /usr/share/nginx/html is index.html.

Change index.html so that the title and header reads "Bienvenue a nginx!" instead of "Welcome to nginx!".

To let the changes take effect, restart Nginx

service nginx reload

When you make a request to http://localhost:8000, you should see the new title and header.

Changing the Nginx configuration to prevent web cache poisoning

Web cache poisoning:

Web cache poisoning is geared towards sending a request that causes a harmful response that then gets saved in the cache and served to other users.

One method of web cache poisoning starts with spoofing the host in the request header.

For example, if your website is written in Django, Django uses the host in the request header when generating its URLs.

Thus if you have a Django URL /my-login, and the host in the request header is malicious-hacker.com (although your site’s domain is my-site.com), if Django internally makes requests to /my-login, these requests will go to http://malicious-hacker.com/my-login and not http://my-site.com/my-login.

http://malicious-hacker.com/my-login then sends a harmful response that gets saved in the cache and served to other users. Web cache poisoning complete!

To mitigate this security risk, Django has an ALLOWED_HOSTS setting.

By setting

ALLOWED_HOSTS = ['my-site.com']

Django will only make requests to my-site.com. If an attacker sends a request with host malicious-hacker.com, it throws a SuspiciousOperation error and returns a Bad request response with status code 400.

One of the drawbacks of the above is that it usually leads to noisy logging due to bots checking for vulnerabilities in your site.

Assuming your website is served by Nginx, one way around this is to configure Nginx so that any request with a host in the header not equal to my-site.com is given a 4xx error response (ideally, the error code should capture just this issue as otherwise all you have done is transferred your noisy logging problem from Django to Nginx).

Let’s now test this workaround locally.

Making a spoof request in Python

import requests

url = 'http://localhost:8000'
headers = {'host': 'abc.com'}
r = requests.get(url, headers=headers)

The response is exactly the same as before, despite having

server_name localhost;

in our server block in default.conf and the host in the request header being abc.com.

Changing the Nginx configuration

So why did we get a 200 response despite the host in the Nginx configuration not matching the host sent in the request?

This is because if Nginx finds no matching server blocks, it uses the first server block.

To check this, let’s add

server {
    listen 80;
    server_name abc.com;
    return 403 "Your request is forbidden";
}

to the end of default.conf. To make the changes take effect,

service nginx reload

Now, making the same request in Python, we get a response status code of 403 with content

'Your request is forbidden'

However, if we spoof the header with a host different to abc.com, we will get a 200 again.

Actually, all we have to do is add

server {
    listen 80;
    return 403 "Your request is forbidden";
}

to the start of default.conf.

Now, whenever the host in the header is not equal to localhost, a 403 is returned.

Although functionally it makes no difference, the convention is to add default_server to the block, i.e. to have

server {
    listen 80 default_server;
    return 403 "Your request is forbidden";
}

at the start of default.conf.

Now your Nginx configuration is setup to prevent web cache poisoning!