An nginx hardening checklist: timeouts, body limits and a check script

Most of nginx's hardening is a handful of settings that nobody remembers to add. Timeouts stop a client from holding a connection open with a trickle of bytes. Body limits stop an upload from filling memory. A version banner tells a scanner exactly what to attack. None of it is hard to set, and none of it is useful unless you test it.

The settings

Put these in the http context, for example /etc/nginx/conf.d/hardening.conf. Ubuntu's default config already includes that directory.

nginx
# /etc/nginx/conf.d/hardening.conf
server_tokens off;
client_header_timeout 10s;
client_body_timeout 10s;
send_timeout 10s;
keepalive_timeout 15s;
client_max_body_size 2m;
large_client_header_buffers 4 8k;

limit_conn_zone $binary_remote_addr zone=perip:10m;

Cap connections per client

limit_conn caps the number of open connections per key. Put it in the server block, and set the number above what a real page with images and a few API calls needs.

nginx
server {
    listen 443 ssl;
    server_name example.com;

    limit_conn perip 20;

    location / {
        proxy_pass http://127.0.0.1:8020;
    }
}

Validate and reload

bash
sudo nginx -t && sudo systemctl reload nginx

Check it from outside with a bash script

This script checks two things from the outside: the version banner is hidden, and a 3 MB upload gets a 413 response before it reaches the app. It exits non-zero on any failure, so it can run from cron.

bash
#!/usr/bin/env bash
# nginx-check.sh: check nginx hardening from outside. Usage: ./nginx-check.sh https://example.com
set -uo pipefail

URL="${1:?usage: $0 https://example.com}"
fails=0

headers=$(curl -sI --max-time 10 "$URL")
if grep -qi '^server: nginx/' <<< "$headers"; then
  echo "FAIL  server version is shown"
  fails=$((fails + 1))
else
  echo "ok    server version hidden"
fi

code=$(head -c 3000000 /dev/zero | curl -s -o /dev/null -w '%{http_code}' \
  --max-time 15 -X POST --data-binary @- "$URL/")
if [ "$code" = "413" ]; then
  echo "ok    oversized body refused (413)"
else
  echo "FAIL  oversized body returned $code, expected 413"
  fails=$((fails + 1))
fi

echo "$fails check(s) failed"
[ "$fails" -eq 0 ]

Run it after every nginx change, and once a week from cron. Drift is what the script is for: a new location block or a copied config can quietly drop a setting.

What the settings don't cover

Timeouts and limits don't replace rate limits, bans or a firewall, which are covered in the other articles in this series. Security headers have their own checklist. See how we took a site from F to A+ on security headers for the header side.

Checking your own response headers by hand gets old fast. osec Scan grades them in seconds, free.

Flow: settings, connection limit, reload, probe from outside, expect 413, drift check
Set once, then test from outside after every change.

Sources

• nginx core module (timeouts, client_max_body_size, server_tokens): https://nginx.org/en/docs/http/ngx_http_core_module.html
• nginx limit_conn module: https://nginx.org/en/docs/http/ngx_http_limit_conn_module.html