Bạn cài Nginx xong, trang web chạy ngon. Vài tuần sau có người gõ thử tenmiencuaban.com/.env và tải về nguyên file chứa mật khẩu database. Chuyện này xảy ra thật với nhiều website, vì cấu hình mặc định của Nginx lo cho web chạy được chứ chưa lo cho web an toàn. Bài này gom những việc mình luôn làm khi dựng máy chủ web, theo thứ tự dễ làm trước.
Mọi cấu hình dưới đây mình đã dựng trên một Nginx 1.30 chạy riêng ở máy thử (cổng cục bộ, thư mục riêng, không đụng máy chủ đang chạy) và kiểm bằng curl, openssl. Đây là ví dụ chung, hãy chỉnh theo app của bạn và thử ở môi trường thử trước.

Việc 1: dặn trình duyệt tự siết chặt (security headers)
Header bảo mật là mấy dòng chỉ dẫn máy chủ gửi kèm mỗi trang, bảo trình duyệt của khách nên cẩn thận hơn. Mình gom chúng vào một file dùng chung:
# snippets/security-headers.conf
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" always;
Nói bằng lời thường thì năm dòng này làm như sau. Strict-Transport-Security bắt trình duyệt chỉ vào domain này bằng HTTPS trong cả năm. X-Content-Type-Options cấm trình duyệt tự đoán loại file, tránh chuyện file tải lên bị chạy như script. Referrer-Policy giới hạn lượng thông tin địa chỉ trang gửi sang trang khác. Permissions-Policy tắt camera, mic, định vị mà trang không dùng. Content-Security-Policy (CSP) là danh sách nguồn được phép nạp script, ảnh, style, nên nếu kẻ xấu chèn được mã vào trang thì thiệt hại giảm mạnh.
Mấy lưu ý từ thực tế. Từ always giúp header vẫn được gửi cả khi trang báo lỗi. Với CSP, hãy bắt đầu bằng Content-Security-Policy-Report-Only để xem cái gì sẽ bị chặn trước khi chặn thật, và tránh 'unsafe-inline' cho script. HSTS chỉ nên bật khi HTTPS đã chạy ổn trên cả domain lẫn subdomain, vì trình duyệt nhớ lệnh này rất lâu. Còn X-XSS-Protection thì đừng thêm, trình duyệt hiện đại đã bỏ.
Bẫy mình thấy nhiều nhất nằm ở đây: Nginx chỉ kế thừa add_header từ cấp ngoài khi cấp hiện tại không có add_header nào. Chỉ cần một khối location tự thêm add_header Cache-Control ... là cả năm header bảo mật biến mất khỏi đường dẫn đó. Mình thử trên bản dựng thật: đường /static/ có add_header riêng cho ra 0 header bảo mật; đường /static-ok/ có include lại file chung thì đủ. Cách chữa là include snippets/security-headers.conf; trong mọi location có add_header riêng.
Việc 2: chặn file không nên công khai
location ~ /\.(?!well-known/) { deny all; }
location ~* ^/(composer\.(json|lock)|package(-lock)?\.json)$ { deny all; }
location ~* \.(sql|bak|old|orig|swp|log)$ { deny all; }
Dòng đầu chặn mọi file ẩn như .env, .git, nhưng chừa /.well-known/ vì việc xin chứng chỉ HTTPS cần thư mục đó. Mình thử bằng curl: /.env, /.git/HEAD, /composer.json, /backup.sql đều trả 403, một file trong /.well-known/ trả 200, trang chủ 200, đường dẫn không có trả 404. Thêm server_tokens off; để máy chủ không khoe số phiên bản, và đặt giới hạn cho kích thước yêu cầu cũng như thời gian chờ để kết nối chậm không giữ chỗ mãi.
Việc 3: cấu hình HTTPS
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_tickets off;
Chỉ giữ TLS 1.2 và 1.3, bỏ hai bản cũ. Mình kiểm nginx -t hợp lệ và dùng openssl s_client bắt tay thử: TLS 1.2 chọn ECDHE-RSA-AES256-GCM-SHA384, TLS 1.3 chọn TLS_AES_256_GCM_SHA384. Danh sách bộ mã hoá cho TLS 1.2 nên lấy từ trình tạo cấu hình của Mozilla, vì khuyến nghị đổi theo thời gian. Chứng chỉ Let's Encrypt thì bạn không cần bật OCSP stapling nữa, vì họ đã ngừng dịch vụ OCSP trong năm 2025 (phần này mình chưa thử vì cần chứng chỉ thật).
Việc 4: giới hạn tốc độ để chặn dò mật khẩu
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_status 429;
server {
location = /login {
limit_req zone=login burst=2 nodelay;
proxy_pass http://app;
}
}
rate=5r/m là 5 yêu cầu mỗi phút cho mỗi địa chỉ IP, burst=2 cho dồn thêm 2 yêu cầu, quá thì trả 429. Mình gọi 6 lần liên tiếp và thấy 200 200 200 429 429 429. Một cái bẫy nhỏ khi thử: limit_req không có tác dụng ở location chỉ có return, vì return chạy trước. Bài thử đầu của mình ra 6 lần 200 cũng vì vậy. Hãy đặt giới hạn ở location có proxy_pass hoặc phục vụ file.
Việc 5: đừng tin IP mà khách tự khai (X-Forwarded-For)
Nhiều app nằm sau Nginx lấy IP khách từ header X-Forwarded-For, và thường lấy phần tử đầu tiên vì tưởng đó là khách. Nhưng header này do khách gửi lên được. Cấu hình phổ biến proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; chỉ nối IP thật vào cuối giá trị khách đã gửi. Kẻ xấu gửi X-Forwarded-For: 1.2.3.4, app nhận "1.2.3.4, <IP thật>" và lấy 1.2.3.4. Mỗi yêu cầu đổi một giá trị thì mỗi yêu cầu thành một "khách mới", và giới hạn tốc độ trong app hết tác dụng.
Mình dựng lại đúng lỗi này: một app nhỏ cho mỗi IP 3 yêu cầu, lấy IP từ phần tử đầu của header, đặt sau Nginx. Gửi 6 yêu cầu, mỗi cái một header khác nhau:
Cấu hình sai (nối thêm vào header): 200 200 200 200 200 200 <- bị lách
Cấu hình đúng (ghi đè bằng IP thật): 200 200 200 429 429 429
Khi Nginx là lớp ngoài cùng, cấu hình đúng là ghi đè:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
}
Tốt hơn nữa là để chính Nginx giới hạn theo $binary_remote_addr như ở việc 4, vì giá trị này lấy từ kết nối thật nên khách không giả được.
Nếu Nginx nằm sau Cloudflare hay một bộ cân bằng tải thì $remote_addr lại là IP của chính proxy đó. Lúc này dùng module realip và chỉ tin header đến từ các địa chỉ proxy bạn khai:
set_real_ip_from 127.0.0.1; # liệt kê dải IP proxy của bạn
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Mình kiểm ba trường hợp. Yêu cầu từ proxy tin cậy mang X-Forwarded-For: 203.0.113.7 thì $remote_addr thành 203.0.113.7. Yêu cầu từ nguồn không tin cậy mang cùng header thì header bị bỏ qua, và đổi giá trị 6 lần vẫn ra 200 200 200 429 429 429. Với X-Forwarded-For: 6.6.6.6, 198.51.100.9 và real_ip_recursive on, kết quả là 198.51.100.9: phần giả ở đầu bị bỏ qua. Lỗi đi kèm hay gặp là khai set_real_ip_from 0.0.0.0/0 hay dải quá rộng, khi đó ai cũng giả được IP.

Lỗi hay gặp
Mình hay gặp chuyện thêm một add_header Cache-Control cho tiện rồi quên là header bảo mật đã biến mất. Cũng hay gặp người bật HSTS khi một subdomain chưa có HTTPS, khiến subdomain đó không vào được. CSP viết default-src * thì có cũng như không. Và mọi lần sửa cấu hình xong, nhớ chạy nginx -t trước khi reload, vì một dấu chấm phẩy thiếu cũng đủ làm web sập.
Nên làm gì tiếp
Chạy curl -I vào vài trang khác nhau để chắc năm header có mặt ở mọi nơi, thử tải /.env và /.git/HEAD xem có 403 không, rồi đặt giới hạn tốc độ cho trang đăng nhập và đặt lại mật khẩu. Phần kiểm này có thể tự động hoá bằng một script hằng ngày, như trong Tự kiểm tra bảo mật website với OWASP Top 10 (2025). Khi đã gia cố xong và muốn nhìn từ góc người ngoài, hãy làm theo quy trình có phép ở Thử xâm nhập website của chính mình (có giấy phép): các bước cơ bản.



