---
title: "Làm website chạy Nginx an toàn hơn, từng bước"
url: https://phatdev.net/vi/blog/nginx-web-hardening
lang: vi
updated: 2026-10-21
alternate:
  en: https://phatdev.net/en/blog/nginx-web-hardening
markdown: https://phatdev.net/vi/blog/nginx-web-hardening.md
---

# Làm website chạy Nginx an toàn hơn, từng bước

> Năm việc nên làm sau khi cài Nginx: header bảo mật, chặn file nhạy cảm, HTTPS, giới hạn tốc độ đăng nhập và cái bẫy IP giả qua X-Forwarded-For, đã dựng và thử thật.

Chuyên mục: Bảo mật | Đăng: 2026-10-19 | Cấp độ: Trung cấp | 7 phút đọc

Tags: nginx, hardening, csp, hsts, rate-limit, x-forwarded-for, tls

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.

![Các lớp phòng thủ bao quanh một máy chủ: header, TLS, giới hạn tốc độ, lọc file](https://phatdev.net/uploads/blog/nginx-web-hardening/layers-1920.webp)

## 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:

```nginx
# 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

```nginx
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

```nginx
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

```nginx
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 đè:

```nginx
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:

```nginx
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.

![Một cổng điều tiết cho các request đi qua, request thừa bị bật lại dù mang nhãn giả](https://phatdev.net/uploads/blog/nginx-web-hardening/ratelimit-1920.webp)

## 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)](https://phatdev.net/vi/blog/owasp-top10-audit-checklist). 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](https://phatdev.net/vi/blog/authorized-web-pentest-basics).

## Nguồn tham khảo

- [Nginx - ngx_http_headers_module (add_header, quy tắc kế thừa)](https://nginx.org/en/docs/http/ngx_http_headers_module.html)
- [Nginx - ngx_http_limit_req_module](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html)
- [Nginx - ngx_http_realip_module](https://nginx.org/en/docs/http/ngx_http_realip_module.html)
- [Nginx - ngx_http_ssl_module](https://nginx.org/en/docs/http/ngx_http_ssl_module.html)
- [MDN - Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
- [MDN - Strict-Transport-Security](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security)
- [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/)
- [Let's Encrypt - Ending OCSP Support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/)
- [MDN - X-Forwarded-For (cảnh báo tin phần tử đầu)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)

## Bài liên quan

- [Tự kiểm tra bảo mật website với OWASP Top 10 (2025)](https://phatdev.net/vi/blog/owasp-top10-audit-checklist.md)
- [Thử xâm nhập website của chính mình (có giấy phép): các bước cơ bản](https://phatdev.net/vi/blog/authorized-web-pentest-basics.md)
- [Passkey là gì: đăng nhập không cần mật khẩu, và có nên thêm vào web của bạn không](https://phatdev.net/vi/blog/passkeys-explained.md)
