Coraza WAF chặn từng request độc hại, nhưng một IP bị chặn vẫn có thể gửi tiếp hàng nghìn request khác, mỗi request vẫn tốn CPU của balancer để phân tích. Bot cào dữ liệu thì còn khó hơn: request của chúng hoàn toàn hợp lệ, chỉ là quá nhiều. Tài liệu này hướng dẫn kết hợp hai lớp: limit_req của nginx giới hạn tốc độ chỉ cho request động, và fail2ban tự động ban ở firewall những IP liên tục bị 403 hoặc 429. Kèm theo là cấu hình mẫu cho balancer, cách chọn ngưỡng và những lỗi đặt ngưỡng hay gặp.
1. Ba lớp chặn trên balancer
Client
│
▼ lớp 1: ufw (danh sách IP do fail2ban ban) ── REJECT ngay ở tầng mạng
│
▼ lớp 2: nginx
│ ├─ limit_req / limit_conn ── vượt ngưỡng → 429
│ ├─ security snippet ── đường dẫn nhạy cảm → 403
│ └─ Coraza WAF + CRS 4 ── payload tấn công → 403
│
▼ proxy_pass sang minion
▲
└── lớp 3: fail2ban đọc access log, đếm 403/429 theo IP → gọi ufwnginx quyết định cho từng request. fail2ban nhìn chuỗi request của một IP theo thời gian và chuyển IP xấu xuống firewall, nơi chặn rẻ nhất: gói tin bị từ chối trước khi nginx phải đọc TLS hay chạy WAF.
2. limit_req hoạt động thế nào?
Module ngx_http_limit_req dùng thuật toán leaky bucket. Mỗi key (thường là IP) có một "xô" chảy đều theo rate. burst là số request được phép xếp hàng khi đến dồn dập. Có nodelay thì request trong burst được phục vụ ngay, không có thì bị giãn ra theo đúng tốc độ. Vượt cả burst thì bị từ chối với mã cấu hình bằng limit_req_status.
Chi tiết quan trọng nhất trong tài liệu: request có key rỗng không bị tính. Đây là mấu chốt để rate limit chỉ áp dụng cho request động.
3. Chỉ giới hạn request động bằng map
Một trang Laravel kéo theo vài chục file CSS, JS, ảnh, font. Nếu đếm tất cả, người dùng thật mở hai ba trang là chạm ngưỡng, trong khi bot cào HTML chỉ gửi request động. Giải pháp: map trả về key rỗng cho file tĩnh.
# /etc/nginx/nginx.conf, trong http {}
map $request_uri $dynamic_key {
default $binary_remote_addr;
~*\.(css|js|mjs|json|woff2?|ttf|otf|eot)$ "";
~*\.(jpg|jpeg|png|gif|svg|ico|webp|avif)$ "";
~*\.(mp4|webm|ogg|mp3|pdf|zip|gz)$ "";
~*/health$ "";
~*/favicon\.ico$ "";
}
limit_req_zone $dynamic_key zone=dynamic:10m rate=25r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=15r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=6r/s;
limit_conn_zone $dynamic_key zone=conn_dynamic:10m;
limit_req_status 429;
limit_conn_status 429;# file cấu hình site trên balancer
location / {
limit_req zone=dynamic burst=50;
limit_conn conn_dynamic 100;
proxy_pass http://10.0.0.21;
...
}Dùng $binary_remote_addr (4 byte với IPv4) thay vì $remote_addr dạng chuỗi giúp zone 10 MB chứa được nhiều IP hơn. Cơ chế này đã được giới thiệu sơ ở bài chuẩn hoá bảo mật Nginx và giới hạn kết nối; ở đây đi sâu vào ngưỡng và phần fail2ban phía sau.
Lưu ý $request_uri chứa cả query string: /css/app.css?v=3 không khớp \.css$ và vẫn bị đếm. Site dùng nhiều asset kiểu này nên map theo $uri.
Mã 429, không phải 503 hay 505
Mặc định nginx trả 503 khi vượt ngưỡng, dễ bị nhầm với lỗi backend. Đặt mã lạ như 505 còn khó hiểu hơn. Nên thống nhất 429 Too Many Requests cho cả limit_req và limit_conn. Nhờ vậy, nhìn dashboard log là phân biệt ngay "bị giới hạn" với "backend lỗi", và fail2ban có một mã riêng để đếm.
Zone riêng cho site nhiều crawler
Ngưỡng chung không hợp với mọi site. Một site tra cứu có rất nhiều trang chi tiết bị crawler cào liên tục, trong khi Googlebot thật cũng cần cào nhanh. Site kiểu này nên có zone riêng, tách trang danh sách và trang chi tiết:
limit_req_zone $dynamic_key zone=site_a_dynamic:10m rate=300r/m;
limit_req_zone $binary_remote_addr zone=site_a_detail:10m rate=120r/m;
# file cấu hình của site-a.example
location / {
limit_req zone=site_a_dynamic burst=100 nodelay;
...
}
location ~ ^/([0-9]+)-([a-z0-9-]+)$ { # trang chi tiết
limit_req zone=site_a_detail burst=40 nodelay;
...
}Các balancer cũng không nhất thiết dùng chung một ngưỡng: máy chạy nhiều site nhỏ có thể đặt 80r/m, máy chạy site lớn giữ 25r/s. Ngưỡng nào hợp phụ thuộc vào loại site trên máy đó. Hãy bắt đầu ở mức rộng, xem log 429 một thời gian rồi mới siết.
4. fail2ban: từ log đến firewall
fail2ban đọc log theo thời gian thực, khớp từng dòng với regex (filter), đếm số lần khớp theo IP trong cửa sổ findtime. Vượt maxretry thì gọi action để ban trong bantime, hết hạn thì tự gỡ. Bộ ba filter + tham số + action gọi là một jail. Mã nguồn và tài liệu ở repo chính thức của fail2ban.
Trên balancer, fail2ban dùng action ufw, tức chèn luật REJECT cho IP vào ufw:
# /etc/fail2ban/jail.d/nginx.conf
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 10
banaction = ufw
banaction_allports = ufw
ignoreip = 127.0.0.1/8 ::1
10.0.0.0/24 # mạng private: minion, database, monitor
<dải IP public của cụm>/24 # các balancer, máy vận hành
<IP văn phòng> # máy chạy công cụ quét SEO nội bộ
[nginx-coraza]
enabled = true
port = http,https
filter = nginx-coraza
logpath = /var/log/nginx/access.log
/var/log/nginx/*.access
maxretry = 5
findtime = 600
bantime = 7200
[nginx-limit-req]
enabled = true
port = http,https
filter = nginx-limit-req
logpath = /var/log/nginx/access.log
/var/log/nginx/*.access
maxretry = 10
findtime = 10
bantime = 3600
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 86400# /etc/fail2ban/filter.d/nginx-coraza.conf
[Definition]
failregex = ^<HOST> .* "(GET|POST|HEAD|PUT|DELETE|PATCH|OPTIONS) .* HTTP/.*" 403 .*$
ignoreregex = ^<HOST>.*(\.css|\.js|\.ico|\.png|\.jpg|\.gif|\.woff|\.svg)|.*(Googlebot|bingbot|Baiduspider|YandexBot|facebookexternalhit)|.*(?i:autodiscover)
# /etc/fail2ban/filter.d/nginx-limit-req.conf
[Definition]
failregex = ^<HOST> - \S+ \[.*\] ".*" 429Hai điều cần hiểu đúng về cấu hình này:
- Jail tên
nginx-corazanhưng thực ra đếm mọi response 403 trong access log, cả 403 do Coraza WAF lẫn 403 do security snippet chặn.env,wp-login. Với bot quét lỗ hổng thì đó là điều mong muốn: 5 lần 403 trong 10 phút là bị ban 2 giờ. ignoreregexbỏ qua file tĩnh, bot tìm kiếm lớn vàautodiscovercủa Outlook. Đánh đổi: kẻ giả User-AgentGooglebotkhông bị đếm, dù WAF vẫn chặn từng request.
Hiệu quả thường thấy ngay sau khi bật: jail nginx-coraza ban rất nhiều IP, điển hình là bot quét .env, .git, phpinfo hay khai thác endpoint WordPress.
Lệnh vận hành hằng ngày
fail2ban-client status # danh sách jail
fail2ban-client status nginx-coraza # số IP đang ban, tổng đã ban
fail2ban-client set nginx-coraza unbanip 198.51.100.7
fail2ban-regex /var/log/nginx/example.com.access /etc/fail2ban/filter.d/nginx-coraza.conf
ufw status numbered | grep -i fail2banfail2ban-regex là lệnh nên chạy mỗi khi sửa filter: nó cho biết bao nhiêu dòng log thật khớp và bao nhiêu bị ignoreregex loại, trước khi để filter đó ban IP thật.
5. Lỗi thường gặp với fail2ban và limit_req
maxretry = 1: một lần 429 là mất kết nối cả giờ
Đặt jail nginx-limit-req với maxretry = 1 nghĩa là chỉ cần một request vượt burst, IP đó bị ban 1 giờ khỏi mọi site trên balancer. Người dùng thật mở nhiều tab, app mobile gọi API song song, hay một văn phòng nhiều người dùng chung một IP NAT đều có thể chạm 429 một lần. Cấu hình mẫu ở trên dùng maxretry = 10 trong findtime = 10 giây: chỉ IP liên tục bị 429 trong 10 giây mới bị ban. Một lần chạm ngưỡng thì nginx tự xử lý bằng 429, không cần đưa xuống firewall.
Ban nhầm IP của chính mình
Các máy trong mạng private, máy vận hành và IP văn phòng chạy công cụ quét SEO nội bộ đều có thể sinh nhiều request trong thời gian ngắn và chạm ngưỡng. Vì vậy ignoreip nên gồm cả dải IP public của cụm và IP văn phòng, không chỉ một IP lẻ. Lưu ý ignoreip chỉ tránh ban; nginx vẫn trả 429 nếu IP đó vượt ngưỡng, nên công cụ quét nội bộ có thể cần thêm một geo whitelist riêng ở tầng nginx.
Ban nhầm IP của Cloudflare
Đây là lỗi nghiêm trọng nhất. Với domain đi qua Cloudflare mà balancer chưa cấu hình realip, IP xuất hiện trong access log của balancer là IP máy chủ biên của Cloudflare, không phải IP người dùng. Bot quét lỗ hổng đi qua Cloudflare nhận 403, fail2ban đếm vào IP của Cloudflare, rồi ban IP đó ở ufw. Từ lúc đó, mọi người dùng thật được Cloudflare định tuyến qua máy biên ấy đều bị từ chối kết nối trong 2 giờ.
Cách phát hiện: đối chiếu danh sách IP đang bị ban với dải IP Cloudflare công bố. Nếu có IP trùng, bạn đang ban nhầm máy biên:
# IP đang bị ban của một jail, mỗi dòng một IP
fail2ban-client get nginx-coraza banip | tr ' ' '\n' > /tmp/banned.txt
# đối chiếu với dải Cloudflare (gói grepcidr trên Debian)
curl -fsS https://www.cloudflare.com/ips-v4 > /tmp/cf.txt
grepcidr -f /tmp/cf.txt /tmp/banned.txtHướng xử lý:
- Trước mắt: thêm các dải IP của Cloudflare vào
ignoreipđể không ban máy biên. - Lâu dài: cấu hình nginx lấy IP thật từ header
CF-Connecting-IPnhư bài lấy đúng IP thật của người dùng sau Cloudflare. Khi đó log có IP thật, nhưng gói tin TCP vẫn đến từ Cloudflare nên ban bằng ufw không còn tác dụng. Với các domain này, việc chặn IP phải chuyển lên Cloudflare (fail2ban có action gọi API Cloudflare) hoặc chặn ở tầng nginx.
Các lỗi khác
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Trang tải thiếu CSS, ảnh khi bị limit | Rate limit đếm cả file tĩnh | Dùng $dynamic_key với key rỗng cho static |
| fail2ban không ban dù log đầy 403 | Log của site không nằm trong logpath, hoặc định dạng log khác filter | fail2ban-client status <jail> xem File list, thử bằng fail2ban-regex |
| fail2ban không ban IP mới sau khi thêm site | Glob *.access chỉ được mở rộng khi fail2ban khởi động | fail2ban-client reload sau khi thêm site |
6. Checklist
- Đếm đúng thứ cần đếm. Rate limit chỉ request động. Key rỗng cho static là chi tiết nhỏ nhưng quyết định người dùng thật có bị chặn hay không.
- nginx xử lý từng lần, fail2ban xử lý thói quen. Một lần 429 không đáng ban. Mười lần trong mười giây thì đáng.
- Biết IP trong log là IP của ai. Sau proxy, CDN hay NAT, một IP có thể là rất nhiều người; log sai IP thì ban sai người.
- Xem lại kết quả ban định kỳ. Danh sách IP bị ban cho thấy bot nào đang quét, và cũng là nơi phát hiện ban nhầm như trường hợp Cloudflare.
Tầng WAF đứng trước được trình bày ở bài Coraza WAF và OWASP CRS 4 trên Nginx.
Bài tiếp theo
Cấp SSL bằng DNS-01 khi server bị chặn kết nối quốc tế. Khi Let's Encrypt không gọi được về balancer qua HTTP, cách xác minh domain bằng bản ghi DNS để vẫn cấp được chứng chỉ.
