Skip to content

09 Fail2Ban Web Protection

Mikhail Deynekin edited this page Oct 20, 2026 · 1 revision

Fail2Ban Web Protection

Most abuse volume on a public web host is scanner noise: /.env, /.git, WordPress probes, PHP shell names, Grafana/Langflow endpoints. This page shows how to turn those requests into reliable jails without banning your own users or your CDN.


Where web attacks live in the logs

Source Typical path What it gives you
nginx access logs /var/www/httpd-logs/*.access.log (ISPmanager), /var/log/nginx/*access.log (manual), one file per vhost request line, status, user agent — the best evidence
nginx error logs /var/www/httpd-logs/*.error.log Primary script unknown, access forbidden by rule, upstream errors
Apache access logs /var/log/apache2/*access.log not used when nginx serves the sites

The project's default filter expects the combined format:

198.51.100.7 - - [02/Jan/2026:03:04:05 +0000] "GET /.env HTTP/1.1" 404 548 "-" "Mozilla/5.0"

If your panel uses a different format, adjust the regex and test with fail2ban-regex before enabling.

Measured scale (one production host, 25 sites, ~3.5 days)

Signal Count
Requests matching attack signatures 42,756 of 96,245 (44%)
/.env 1,216
/.env.local 839
/@fs/.env 719
/__vite_rsc_findSourceMapURL 715
/.git/config 388
/wp-admin/install.php 295
/.ssh/id_rsa 186
Primary script unknown (non-existent PHP) 6,176

Conclusion: web jails are not optional on a public host — they see more unique IPs than SSH.

Filter 1 — known attack paths (access log)

/etc/fail2ban/filter.d/nginx-attack-scan.conf:

[Definition]
# Combined log format.  fail2ban removes the date token before matching, so the
# date block is skipped with [^"]* instead of being reproduced literally.
# NOTE: %% is an escaped percent sign in fail2ban config files.
failregex = ^<HOST> - - [^"]*"(?:GET|POST|HEAD|PUT|PATCH|OPTIONS|DELETE|PROPFIND|TRACE|CONNECT) [^"]*(?:\.env|\.git/|\.svn/|\.hg/|\.aws/|\.ssh/|\.DS_Store|/@fs/|__vite|/proc/self|/etc/passwd|/id_rsa|wp-admin/install\.php|wlwmanifest\.xml|phpmyadmin|/pma/|adminer|/myadmin|/config\.json|/\.vscode/|sftp\.json|terminal-xhr\.php|file-control\.php|icecoder|api/console/api_server|/api/fs/read|/api/system/fileView|boaform|HNAP|/solr/|/actuator|/owa/|/autodiscover|/ecp/|/manager/html|/cgi-bin/|%%2e%%2e|\.\./)[^"]*"

ignoreregex =

/etc/fail2ban/jail.d/99-web-jails.local:

[nginx-attack-scan]
enabled  = true
filter   = nginx-attack-scan
logpath  = /var/www/httpd-logs/*.access.log
port     = 80,443
maxretry = 5
findtime = 10m
bantime  = 1d

port = 80,443 matters. Without it the jail inherits port = 0:65535 and the firewall action bans every port on the address — a false positive then takes the client's SSH and mail with it. This was measured in production.

Filter 2 — WordPress login brute force (POST only)

Do not ban on GET /wp-login.php: that is a legitimate page an administrator opens. Match only POST requests, with a higher threshold:

# /etc/fail2ban/filter.d/nginx-wp-login.conf
[Definition]
failregex = ^<HOST> - - [^"]*"POST [^"]*wp-login\.php
ignoreregex =
[nginx-wp-login]
enabled  = true
filter   = nginx-wp-login
logpath  = /var/www/httpd-logs/*.access.log
port     = 80,443
maxretry = 10
findtime = 10m
bantime  = 1d

Measured: 34 POST requests in 3 days on a quiet host, 33 of them from one address.

Filter 3 — PHP shell probing (error log)

A scanner requesting /<random>.php makes PHP-FPM answer Primary script unknown; nginx writes it to the error log. This is a very strong signal and produces almost no legitimate noise:

# /etc/fail2ban/filter.d/nginx-php-probe.conf
[Definition]
failregex = ^\s*\[error\] \d+#\d+: \*\d+ FastCGI sent in stderr: "Primary script unknown" while reading response header from upstream, client: <HOST>,

# nginx error log date: 2026/01/02 03:04:05
datepattern = {^LN-BEG}%%ExY(?P<_sep>[-/.])%%m(?P=_sep)%%d[T ]%%H:%%M:%%S(?:[.,]%%f)?(?:\s*%%z)?
              {^LN-BEG}
ignoreregex =

Note the ^\s*\[error\]: the timestamp is stripped before matching, leaving a leading space.

[nginx-php-probe]
enabled  = true
filter   = nginx-php-probe
logpath  = /var/www/httpd-logs/*.error.log
           /var/log/nginx/error.log
port     = 80,443
maxretry = 10
findtime = 10m
bantime  = 1d

Measured: 6,176 matches; top sources 1,103 / 971 / 936 requests.

Filter 4 — requests blocked by server rules

If nginx itself denies a path (access forbidden by rule), it is already a confirmed attack:

# built-in filter: nginx-forbidden
[nginx-forbidden]
enabled  = true
logpath  = /var/www/httpd-logs/*.error.log
maxretry = 10
findtime = 1h
bantime  = 1d

Filter 5 — rate limiting (optional)

If you configure limit_req in nginx, the standard nginx-limit-req filter works on the error log. It is not enabled by the project: rate-limit thresholds are business decisions and deserve their own tuning.

Testing before enabling (mandatory)

# 1. positive test: does the filter match the attack paths?
fail2ban-regex '/var/www/httpd-logs/*.access.log' \
               /etc/fail2ban/filter.d/nginx-attack-scan.conf \
  | tail -5
# expect: Failregex: <N> total ... matched

# 2. inspect what matched — must be only attack-like URLs
fail2ban-regex '/var/www/httpd-logs/*.access.log' \
               /etc/fail2ban/filter.d/nginx-attack-scan.conf \
  --print-all-matched | head -40

# 3. negative test: a filter must NOT match ordinary requests
grep -h '"GET / ' /var/www/httpd-logs/*.access.log | head -200 > /tmp/normal.log
fail2ban-regex /tmp/normal.log /etc/fail2ban/filter.d/nginx-attack-scan.conf
# expect: 0 matched

# 4. validate the whole configuration and apply
fail2ban-client -t && systemctl restart fail2ban

# 5. verify the actions are attached
fail2ban-client get nginx-attack-scan actions

Measured on the reference host: 42,756 matches from 96,244 lines for the access filter; 6,176 for the PHP-probe filter; zero matches on the "normal page" sample.

False positives: who must not be banned

Risk Mitigation
WordPress/phpMyAdmin administrators ban only POST login attempts, threshold 10/10m; add admin IPs to ignoreip
SEO crawlers (Googlebot, Bingbot, Semrush) they request many 404s; do not ban on generic 404, only on attack signatures; respect ignoreip for known crawler ranges if needed
Monitoring / uptime checks add monitoring IPs to ignoreip
Your offices / partners add to ignoreip (both jails and [DEFAULT])
CDN edge addresses see Cloudflare section below
Application APIs that legitimately poll /api/... narrow the signature list, exclude the app's real paths

A filter that matches ordinary pages is worse than no filter: it bans customers.

Cloudflare and other CDNs (important)

If sites are behind Cloudflare, nginx sees Cloudflare edge IPs, not the real client:

  • fail2ban would ban the edge address (and possibly break the site for many users);
  • banning a real client IP at the firewall does nothing, because traffic arrives from the edge.

Options:

  1. Preferred: enable ngx_http_realip_module so logs (and fail2ban) see the real client:

    set_real_ip_from 173.245.48.0/20;
    set_real_ip_from 103.21.244.0/22;
    # ... all Cloudflare ranges, see https://www.cloudflare.com/ips/
    real_ip_header CF-Connecting-IP;

    Then use Cloudflare's own firewall/WAF for edge blocking; firewall bans of client IPs will not affect the TCP connection to the edge.

  2. Keep the CDN ranges in [ignore] ranges of the reporter so a CDN edge is never reported.

  3. If you cannot use real-IP, do not enable web jails that ban on requests: you would only punish the CDN.

The project's example configuration ships with all Cloudflare IPv4/IPv6 ranges in the reporter's ignore list. That list protects reporting, not the firewall: firewall rules need their own whitelist (ignoreip).

Multiple sites and new virtual hosts

The examples use a glob (/var/www/httpd-logs/*.access.log). Fail2ban expands the glob when the jail starts. After adding a new site:

systemctl reload fail2ban
fail2ban-client status nginx-attack-scan | grep "File list"

Logs are rotated by the panel; fail2ban follows rotation, but a newly created file is only picked up on reload/restart.

IPv6

<HOST> matches IPv4 and IPv6. Ensure:

  • ignoreip contains your IPv6 management prefixes;
  • each web jail has port = 80,443 (same rule);
  • the nftables/iptables action supports the family (the nftables action creates separate sets).

Rollout checklist

  1. Backup /etc/fail2ban.
  2. Add filter files; test each with fail2ban-regex on real logs (positive + negative).
  3. Add jails with port = 80,443, findtime, maxretry, bantime.
  4. fail2ban-client -t → OK.
  5. Restart fail2ban.
  6. Verify fail2ban-client get <jail> actions shows nftables, abuseipdb-async.
  7. Watch the jail for 24 hours: fail2ban-client status <jail>, review bans, check for customer complaints, look at nft list table inet f2b-table.
  8. Only then increase bantime / add recidive.

Next: SSH, Mail, FTP · Verification & Rollback · Agent Prompts: Fail2Ban Setup

Clone this wiki locally