Installing ModSecurity v3 and OWASP CRS for Nginx on CentOS Stream 9

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

The 2 AM Log-Checking Nightmare

At 2:00 AM, PagerDuty started screaming. The Grafana dashboard was glowing bright red: server CPU was pinned at 100%, and over 45,000 junk requests flooded the /api/v1/search endpoint in just three minutes. Opening the Nginx access log, a long stream of familiar exploit payloads stared right back at me:

tail -f /var/log/nginx/access.log | grep -E "UNION|SELECT|<script>"

The Node.js backend completely exhausted its PostgreSQL connection pool. Layer 3/4 firewalls like IPTables and Firewalld were completely useless—every packet arrived over valid port 443 connections with flawless SSL handshakes. At this point, the system needed an application layer (Layer 7) filter to inspect URIs, query strings, and request bodies before routing them to the backend. That is precisely why you need ModSecurity v3 combined with the OWASP Core Rule Set (CRS).

How Do ModSecurity v3 and OWASP CRS Work Together?

Before diving into commands, let’s clarify the role of each component:

  • ModSecurity v3 (libmodsecurity): A standalone traffic inspection engine written in C++. Unlike v2, which was tightly coupled to Apache, v3 runs as an independent library and interfaces with Nginx via the modsecurity-nginx dynamic module. It parses HTTP packets, inspects headers, buffers request bodies, and executes rule evaluations.
  • OWASP Core Rule Set (CRS): An open-source set of security rules continuously maintained by the community. CRS detects most vulnerabilities listed in the OWASP Top 10, including SQL Injection, Cross-Site Scripting (XSS), Local File Inclusion (LFI), and automated scanner detection. It operates using an anomaly scoring model: each suspicious pattern increments the request’s anomaly score. If the cumulative score exceeds a defined threshold (5 by default), Nginx immediately terminates the connection with a 403 Forbidden response.

Deployment on CentOS Stream 9

The following steps should be executed as root or by a user with sudo privileges.

Step 1: Enable EPEL Repository and Install Prerequisites

CentOS Stream 9 requires the EPEL and CRB (CodeReady Builder) repositories to provide all required development libraries:

dnf install -y epel-release
dnf config-manager --set-enabled crb
dnf update -y

dnf install -y gcc-c++ make automake autoconf libtool libxml2-devel \
    pcre2-devel libcurl-devel yajl-devel geoip-devel zlib-devel git

Step 2: Install Nginx and the ModSecurity Module

The EPEL 9 repository already provides pre-packaged Nginx and its compatible connector module. You don’t need to spend 20 minutes compiling Nginx from source:

dnf install -y nginx nginx-mod-modsecurity
systemctl enable --now nginx

Verify that Nginx supports dynamic modules:

nginx -V 2>&1 | grep -o "--with-compat"

Step 3: Prepare the ModSecurity Configuration Files

Download the recommended configuration files from SpiderLabs into the Nginx configuration directory:

mkdir -p /etc/nginx/modsec
cd /etc/nginx/modsec

curl -fsSL https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/modsecurity.conf-recommended -o modsecurity.conf
curl -fsSL https://raw.githubusercontent.com/owasp-modsecurity/ModSecurity/v3/master/unicode.mapping -o unicode.mapping

Specify the audit log path and increase the request body limit to 20MB to prevent legitimately uploaded user files from being dropped:

sed -i 's|SecAuditLog /var/log/modsec_audit.log|SecAuditLog /var/log/nginx/modsec_audit.log|' /etc/nginx/modsec/modsecurity.conf
sed -i 's/SecRequestBodyLimit 13107200/SecRequestBodyLimit 20971520/' /etc/nginx/modsec/modsecurity.conf

Initially, leave SecRuleEngine DetectionOnly as is. This setting allows the WAF to log violations without actively blocking traffic, preventing disruptions to production services.

Step 4: Load the OWASP CRS v4 Rule Set

Clone the stable release of CRS v4 from GitHub:

cd /etc/nginx/modsec
git clone --branch v4.0.0 https://github.com/coreruleset/coreruleset.git owasp-crs
cd owasp-crs
cp crs-setup.conf.example crs-setup.conf

Create the master loader file /etc/nginx/modsec/main.conf so Nginx loads all rules in sequence:

cat << 'EOF' > /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/nginx/modsec/owasp-crs/crs-setup.conf
Include /etc/nginx/modsec/owasp-crs/rules/*.conf
EOF

Step 5: Enable the WAF on the Nginx Virtual Host

Open your vhost configuration file (for example, /etc/nginx/conf.d/default.conf) and add the ModSecurity directives inside the server block:

server {
    listen 80;
    server_name example.com;

    # Enable ModSecurity
    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/main.conf;

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

Test the configuration syntax:

nginx -t

Once you see syntax is ok, reload Nginx:

systemctl reload nginx

Step 6: Live Testing and Inspecting the Audit Log

Send a legitimate request to verify connectivity:

curl -I http://localhost/

The server should respond with HTTP/1.1 200 OK. Next, attempt sending a classic SQL Injection payload:

curl -I "http://localhost/?id=1%27%20OR%20%271%27=%271"

Inspect the audit log to see how the engine evaluated the request in detail:

tail -n 35 /var/log/nginx/modsec_audit.log

In the log, you will see rule ID 942100 (SQL Injection Detected via libinjection) trigger and add 5 anomaly points. Once the system is stable and you have whitelisted application-specific edge cases, switch to active blocking mode by updating this directive in /etc/nginx/modsec/modsecurity.conf:

SecRuleEngine On

Reload Nginx. If you re-run the previous curl command containing the SQLi payload, the server will immediately return an HTTP/1.1 403 Forbidden status code.

Production Operation Insights

Never switch to SecRuleEngine On directly in production without an observation period. Run in DetectionOnly mode for at least 48 to 72 hours. Filter modsec_audit.log to identify legitimate requests flagged incorrectly (false positives)—such as JSON payloads containing special characters or CMS Markdown input.

When false positives occur, use the SecRuleRemoveById directive in a dedicated override file rather than disabling an entire rule category. This approach maintains a tight security posture without breaking legitimate user workflows.

Share: