The Problem with One-Size-Fits-All WAFs#
On a shared server, a single ModSecurity configuration creates friction. The WordPress site on site-a.com needs wp-login.php accessible, but the custom PHP app on site-b.com has a form that triggers the same rule. You end up disabling rules globally, weakening protection for everyone.
The fix is per-site ModSecurity rules. Instead of one modsecurity.conf for the entire server, each virtual host gets its own rule set, tuned to the application it runs. This guide walks through the exact steps to set this up with Nginx and ModSecurity v3 (the modsecurity-nginx connector).
Prerequisites#
Before you start, make sure you have:
- Nginx compiled with the ModSecurity dynamic module (
ngx_http_modsecurity_module.so) - ModSecurity v3 (
libmodsecurity) installed - A base rule set like OWASP Core Rule Set (CRS) v4.x
- Separate Nginx
serverblocks for each site (standard for shared hosting)
If you haven't compiled ModSecurity for Nginx yet, the ModSecurity v3 Nginx connector repo has build instructions. Most distros now ship libmodsecurity3 in their repos.
Step 1: Create a Global ModSecurity Base Config#
This file holds the engine settings that apply everywhere. Create /etc/nginx/modsec/main.conf:
# /etc/nginx/modsec/main.conf
SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecAuditLogType Serial
SecAuditLog /var/log/modsec_audit.log
# Include the CRS setup (tune paranoia level here)
Include /etc/nginx/modsec/crs/crs-setup.conf
Include /etc/nginx/modsec/crs/rules/*.confThis is your baseline. The OWASP CRS rules load here, and every site inherits them unless you override per-site.
Step 2: Create Per-Site Rule Override Directories#
Create a directory for each site's custom rules:
mkdir -p /etc/nginx/modsec/sites/site-a.com
mkdir -p /etc/nginx/modsec/sites/site-b.comInside each, create a custom-rules.conf file. For example, for a WordPress site:
# /etc/nginx/modsec/sites/site-a.com/custom-rules.conf
# Allow XML-RPC (needed by Jetpack, some mobile apps)
SecRule REQUEST_URI "@beginsWith /xmlrpc.php" \
"id:10001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"
# Allow wp-admin POST requests without triggering SQLi rules
SecRule REQUEST_URI "@beginsWith /wp-admin/admin-ajax.php" \
"id:10002,phase:1,pass,nolog,ctl:ruleRemoveById=942100,ctl:ruleRemoveById=942200"For a custom PHP app that sends JSON payloads:
# /etc/nginx/modsec/sites/site-b.com/custom-rules.conf
# Don't inspect JSON bodies for this app (false positives on nested objects)
SecRule REQUEST_URI "@beginsWith /api/" \
"id:20001,phase:1,pass,nolog,ctl:requestBodyProcessor=URLENCODED"
# Whitelist a specific POST endpoint
SecRule REQUEST_URI "@beginsWith /api/webhook" \
"id:20002,phase:1,pass,nolog,ctl:ruleRemoveById=920350,ctl:ruleRemoveById=942100"The key is using ctl:ruleRemoveById to selectively disable CRS rules per site, rather than disabling them globally.
Step 3: Create a Per-Site ModSecurity Include File#
Each site needs a small file that loads the global config and then its own rules. Create /etc/nginx/modsec/sites/site-a.com/modsec.conf:
# /etc/nginx/modsec/sites/site-a.com/modsec.conf
Include /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/sites/site-a.com/custom-rules.confRepeat for every site. This is the single file Nginx will reference.
Step 4: Wire It Into Nginx Server Blocks#
In each site's Nginx server block, point to the site-specific include:
server {
listen 80;
server_name site-a.com www.site-a.com;
root /var/www/site-a.com/public;
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/sites/site-a.com/modsec.conf;
# ... rest of config
}Do the same for site-b.com, pointing to its own modsec.conf.
Important: modsecurity_rules_file is an absolute path. Don't use relative paths — Nginx resolves them from its working directory, not the config file's location.
Step 5: Test and Reload#
Validate the Nginx config before reloading:
nginx -tIf it passes, reload:
systemctl reload nginxTest that rules are firing per-site. Send a known CRS trigger (SQL injection test string) to both sites:
curl -I "https://site-a.com/?id=1%20AND%201=1"
curl -I "https://site-b.com/?id=1%20AND%201=1"Check /var/log/modsec_audit.log to confirm the rule IDs and which site triggered them. If you've whitelisted a rule for one site, it should still block on the other.
Step 6: Add Per-Site Audit Logging (Optional)#
If you want separate audit logs per site (useful when debugging customer complaints), add this to each site's custom-rules.conf:
SecAuditLog /var/log/modsec/site-a.com.log
SecAuditLogType SerialThis overrides the global SecAuditLog for that site only.
Automating This for New Accounts#
If you're running a hosting platform, you'll want to automate this on account creation. The minimum viable automation:
- Create the site directory and
custom-rules.conffrom a template based on detected application type (WordPress, Laravel, generic PHP) - Generate the
modsec.confinclude file - Add the
modsecurity_rules_filedirective to the Nginx vhost - Reload Nginx
Platforms like Salieno Core handle this as part of account provisioning — each site gets its own ModSecurity profile without manual intervention. But even a shell script triggered by your WHMCS provisioning hook can do the job.
Tuning Tips#
- Start with CRS Paranoia Level 1. PL2 and above generate significantly more false positives on shared hosting.
- Use `SecRuleRemoveById` in per-site files, not in the global CRS config. This keeps your base config clean.
- Monitor `/var/log/modsec_audit.log` weekly. Look for repeated false positives on specific rule IDs and fix them per-site.
- Never set `SecRuleEngine Off` in a per-site file. If a customer's app breaks, fix the specific rule, don't kill the WAF.
The Payoff#
Per-site ModSecurity rules mean you can run aggressive protection on sites that need it (ecommerce, login-heavy apps) while giving more breathing room to static sites or well-secured CMS installs. Your customers stop complaining about blocked requests, and you stop weakening the WAF for everyone to accommodate one noisy site. It's more work upfront, but it's the difference between a WAF that stays on and one that gets quietly disabled in production.
0 comments
Loading comments…