Mở access log của bất kỳ balancer nào cũng thấy cùng một kiểu request lặp đi lặp lại: /.env, /.git/config, /wp-login.php, /phpmyadmin/, /vendor/phpunit/.... Site Laravel không có WordPress hay phpMyAdmin nào, nhưng bot dò lỗ hổng không quan tâm. Không có lớp bảo mật Nginx phía trước, mỗi request như vậy lọt xuống minion sẽ chiếm một worker PHP chỉ để trả về 404. Tài liệu này hướng dẫn chuẩn hoá bảo mật Nginx trên tầng balancer bằng hai thứ: một security snippet dùng chung để chặn đường dẫn nhạy cảm, và bộ giới hạn limit_req/limit_conn theo IP, cùng quy trình triển khai lên hàng chục file cấu hình site mà không làm sập site nào.
1. Vì sao bảo mật Nginx phải đặt ở balancer
Trong mô hình Cloud VPS ba tầng, balancer là cổng vào duy nhất có IP public. Đặt lớp bảo mật Nginx ở đây có ba lợi ích:
- Rẻ: nginx trả 403 cho một request xấu tốn rất ít CPU, so với để PHP-FPM khởi động Laravel rồi mới trả 404.
- Đồng nhất: một luật áp dụng cho mọi site, không phụ thuộc site đó viết bằng framework nào hay có tự chặn
.envhay không. - Không đụng code ứng dụng: đội vận hành tự chủ, không cần chờ lập trình viên từng dự án.
Khi luật chặn nằm rải rác trong từng file cấu hình site (chép từ template tại thời điểm tạo site, hoặc viết tay mỗi người một kiểu), mức bảo mật của mỗi site phụ thuộc vào việc nó được tạo lúc nào và bởi ai. Chuẩn hoá bảo mật Nginx nghĩa là đưa mọi site về cùng một bộ luật.
2. Security snippet: một file bảo mật Nginx cho mọi site
Gom toàn bộ luật bảo mật Nginx vào một file /etc/nginx/snippets/security-blocking.conf trên mỗi balancer, và mỗi file cấu hình site chỉ cần một dòng include. Snippet mẫu:
# /etc/nginx/snippets/security-blocking.conf
# 1. File nhạy cảm: dump database, script, biến môi trường, git, htaccess
location ~* \.(sql|sh|bash|env|git|htaccess)$ {
return 403;
}
location ~ /\.(git|svn|env) {
return 403;
}
# 2. Đường dẫn bot hay dò: WordPress, phpMyAdmin, Adminer
location ~* (wp-admin|wp-login|xmlrpc\.php|phpmyadmin|adminer) {
return 403;
}
# 3. Công cụ debug, giám sát của Laravel và file lộ cấu hình
location ~* (vendor/phpunit|_ignition|telescope|horizon|\.json\.bak) {
return 403;
}Nhóm 3 đáng chú ý với site Laravel: _ignition là trang lỗi debug từng có lỗ hổng thực thi mã từ xa khi APP_DEBUG=true, còn telescope và horizon là bảng điều khiển nội bộ không nên lộ ra Internet. Lớp bảo mật Nginx ở balancer là hàng phòng thủ thứ hai, phòng khi một site lỡ bật debug trên production.
Trong file cấu hình site, dòng include đặt ngay trước location / {:
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Nạp cấu hình chặn bảo mật dùng chung
include snippets/security-blocking.conf;
location / {
limit_req zone=dynamic burst=50;
limit_conn conn_dynamic 100;
proxy_pass http://10.0.0.21;
# ...
}
}Thứ tự location: vì sao snippet thắng location /
Theo tài liệu chỉ thị location của nginx, nginx tìm location tiền tố dài nhất trước (ở đây là location /), ghi nhớ nó, rồi kiểm tra các location regex theo thứ tự xuất hiện. Regex đầu tiên khớp sẽ được dùng. Vì /.env khớp regex của snippet, request bị trả 403 dù location / cũng khớp. Hai hệ quả:
- Đặt include ở đâu trong khối
servercũng được, nhưng đặt trướclocation /giúp người đọc thấy ngay. - Nếu một site tự khai location regex riêng trước dòng include, regex đó sẽ thắng snippet. Khi rà soát, đọc cả file chứ không chỉ kiểm tra có dòng include.
Cái giá: regex quá rộng
Regex dạng (…|horizon) không có ranh giới từ, nên khớp với mọi URL chứa chuỗi đó: một bài viết có slug /horizon-zero-dawn sẽ bị 403. Chặn wp-admin là đúng với site Laravel nhưng làm hỏng một site WordPress thật nếu có trên cùng balancer. Bảo mật Nginx kiểu chặn theo chuỗi luôn có đánh đổi này. Cách giảm: neo regex vào đầu đường dẫn (^/(wp-admin|phpmyadmin)) khi được, và tách snippet riêng cho nhóm site khác framework. Khi một site báo 403 bất thường, việc đầu tiên là kiểm tra URL đó có khớp snippet không.
3. Bảo mật Nginx bằng giới hạn request và kết nối theo IP
Snippet bảo mật Nginx chặn những thứ chắc chắn xấu. Còn request trông bình thường nhưng đến quá dày, như crawler cào hàng nghìn trang sản phẩm mỗi phút, thì cần giới hạn tốc độ. Nginx có hai module: ngx_http_limit_req_module giới hạn số request theo thời gian, và ngx_http_limit_conn_module giới hạn số kết nối đồng thời. Zone khai một lần trong khối http:
http {
# 10 MB vùng nhớ chia sẻ, đủ cho khoảng 160.000 địa chỉ IP
limit_req_zone $binary_remote_addr zone=dynamic:10m rate=25r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=6r/s;
limit_conn_zone $binary_remote_addr zone=conn_dynamic:10m;
# mặc định cả hai trả 503, đổi sang 429 Too Many Requests
limit_req_status 429;
limit_conn_status 429;
}Mỗi site áp dụng trong location / như ở mục 2. Cách đọc cấu hình mẫu: mỗi IP được 25 request mỗi giây; vượt mức đó, tối đa 50 request được xếp hàng (burst=50) và nhả dần theo nhịp 25r/s; quá 50 request trong hàng đợi thì bị trả 429 ngay. Đồng thời mỗi IP không giữ quá 100 kết nối mở cùng lúc. Các con số là điểm khởi đầu, cần thử trên trang nặng nhất (nhiều CSS, JS, ảnh) trước khi siết.
429 chứ không phải 503
Mặc định limit_req và limit_conn trả 503 Service Unavailable. Mã này gây hiểu lầm cho cả người lẫn máy: thấy 503 tăng, người trực sẽ nghĩ minion sập; Googlebot gặp 503 hiểu là server đang quá tải. Mã đúng là 429 Too Many Requests, nói rõ "bạn gửi quá nhiều", và client đàng hoàng sẽ tự giảm tốc. Cẩn thận lỗi gõ nhầm kiểu limit_conn_status 505: 505 là "HTTP Version Not Supported", không liên quan tới giới hạn kết nối.
Lưu ý: giới hạn theo IP nào
$binary_remote_addr là IP của bên kết nối trực tiếp tới balancer. Với site đi thẳng từ Internet, đó là IP người dùng. Với domain đặt sau Cloudflare, đó là IP của Cloudflare, và rất nhiều người dùng sẽ chung một "hạn mức" 25r/s. Vấn đề này đủ lớn để có một bài riêng ngay sau.
4. Triển khai bảo mật Nginx hàng loạt mà không làm sập site
Viết snippet bảo mật Nginx thì dễ. Khó là đưa dòng include và dòng limit_req vào hàng chục file cấu hình site trên mỗi balancer, nhiều file viết tay với định dạng không đồng nhất. Sửa tay thì sai sót, còn một lệnh sed sai là hỏng cùng lúc mọi site. Nên tách thành các job Jenkins riêng (xem Jenkins làm "control plane" cho hàng trăm website): một job tạo hoặc cập nhật file snippet, một job chèn include vào file site, một job chèn limit_req/limit_conn. Mọi job bảo mật Nginx theo cùng một khung: backup, sửa, nginx -t, reload hoặc rollback.
Bước 1: backup cả thư mục, có dấu thời gian
TS=$(date +%Y%m%d_%H%M%S)
BACKUP=/root/nginx_migration_backup/backup_$TS
mkdir -p "$BACKUP"
cp -v /etc/nginx/conf.d/*.conf "$BACKUP"/Thư mục backup phải mang dấu thời gian. Nếu dùng một thư mục cố định, lần chạy thứ hai sẽ ghi đè bản backup bằng chính các file đã sửa ở lần đầu, và bản gốc mất vĩnh viễn.
Bước 2: sửa có điều kiện, bỏ qua file đã xử lý
Có thể đọc từng file về Jenkins và xử lý bằng Groovy thay vì sed trên server, để kiểm soát chính xác chỗ chèn:
// đã có include thì bỏ qua: chạy lại job bao nhiêu lần cũng an toàn
if (content =~ /include\s+['"]?snippets\/security-blocking\.conf['"]?\s*;/) {
skipped << domainName
continue
}
// chỉ chèn trước dòng "location / {" đầu tiên, giữ nguyên thụt lề
if (trimmed.replace(' ', '') == 'location/{') {
newLines.add(indent + "include snippets/security-blocking.conf;")
}File nào không có location / { được bỏ qua và liệt kê ở cuối build. Danh sách "đã sửa" và "bỏ qua" phải được đọc sau mỗi lần chạy, vì một file bị bỏ qua là một site nằm ngoài lớp bảo mật Nginx chung.
Bước 3: nginx -t trước khi reload, sai thì trả lại
def test = sh(script: "ssh deploy@${BALANCER} 'sudo nginx -t 2>&1'", returnStdout: true)
if (test.contains("test is successful")) {
sh "ssh deploy@${BALANCER} 'sudo systemctl reload nginx'"
} else {
// chép lại toàn bộ file từ thư mục backup
sh "ssh deploy@${BALANCER} 'sudo cp ${BACKUP}/*.conf /etc/nginx/conf.d/'"
error("nginx -t failed")
}Biến thể an toàn hơn: chạy nginx -t sau từng file; file nào làm hỏng cú pháp thì chỉ file đó được trả lại và job dừng, in nội dung file kèm số dòng. Chậm hơn nhưng chỉ ra chính xác thủ phạm. Điểm mấu chốt: chừng nào chưa reload, nginx vẫn chạy cấu hình cũ trong bộ nhớ, nên lỗi phát hiện ở nginx -t chưa ảnh hưởng tới người dùng.
Bước 4: kiểm tra hành vi, không chỉ cú pháp
nginx -t chỉ biết cú pháp đúng, không biết luật bảo mật Nginx có chặn đúng không. Sau mỗi đợt, kiểm tra bằng curl trên vài site đại diện:
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.env
403
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-login.php
403
$ curl -s -o /dev/null -w '%{http_code}\n' https://example.com/
200
# thử giới hạn: bắn 200 request song song, một phần sẽ nhận 429
$ seq 200 | xargs -P 50 -I{} curl -s -o /dev/null -w '%{http_code}\n' https://example.com/ | sort | uniq -c5. Checklist chuẩn hoá bảo mật Nginx
- Một nguồn sự thật: luật bảo mật Nginx nằm trong một snippet, file cấu hình site chỉ giữ phần riêng.
- Backup có dấu thời gian: backup ghi đè lên chính nó thì cũng như không có.
- Job hàng loạt chạy lại được: kiểm tra "đã có chưa" trước khi chèn, liệt kê file bỏ qua.
- Tách cú pháp và hành vi:
nginx -tbảo vệ khỏi sập, curl bảo vệ khỏi "chạy nhưng sai". - Mã lỗi là giao diện: 429 cho giới hạn, 403 cho luật chặn.
- Regex càng rộng càng dễ chặn nhầm: neo regex khi được, có quy trình xử lý khi có báo 403.
Snippet và limit là lớp bảo mật Nginx nền tảng, đơn giản và rẻ. Chúng không thay được WAF hay fail2ban, nhưng loại bỏ được phần lớn request rác trước khi chúng chạm tới PHP.
Bài tiếp theo
Lấy đúng IP thật của người dùng sau Cloudflare và balancer. Vì sao $remote_addr trên minion luôn là IP balancer, vì sao rate limit theo IP trở nên vô nghĩa khi site đứng sau Cloudflare, và cách dùng module realip của nginx để sửa.
