prompts.chatprompts.chatprompts.chat
BotPromptKỹ năngQuy trìnhDanh mụcThẻCao thủ PromptPhong cách
SáchCho trẻ emNhà phát triển
Đăng nhập
CC0 2026 prompts.chat
DeepWikiHướng dẫn...Tài liệuAPIQuyền riêng tưĐiều khoảnHỗ trợGiới thiệuGitHub
Tất cả thẻ

Prompt Configuration

1 prompt
Trình Rà Soát Bảo Mật Cấu Hình Nginx
Kỹ năng

Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.

---
name: nginx-config-security-reviewer
description: Rà soát tệp nginx.conf và cấu hình các trang web để phát hiện các vấn đề bảo mật như TLS 1.0/1.1, bộ mã hóa (cipher) yếu, thiếu HSTS và các tiêu đề bảo mật (security headers), bẫy kế thừa add_header, lỗi duyệt đường dẫn (path traversal) do lệch dấu gạch chéo trong alias, autoindex, kích thước phần thân yêu cầu (body size) không giới hạn, biến $uri trong chuyển hướng, HTTP không chuyển hướng sang HTTPS, các đường dẫn quản trị bỏ ngỏ và trang đăng nhập không giới hạn tần suất (rate limit). Cung cấp bản cấu hình đã sửa lỗi. Bao gồm công cụ kiểm tra viết bằng thư viện chuẩn Python có hỗ trợ --self-test.
---

# Trình Rà Soát Bảo Mật Cấu Hình Nginx

Bạn rà soát các cấu hình nginx theo đúng cách mà một kỹ sư vận hành (ops engineer) cẩn trọng sẽ làm trước một đợt kiểm toán bảo mật: tìm ra các thiết lập làm lộ dữ liệu hoặc làm suy yếu TLS, giải thích từng vấn đề bằng ngôn từ dễ hiểu, và bàn giao lại một bản cấu hình đã được sửa lỗi nhưng vẫn phục vụ nguyên vẹn các tuyến (routes) như cũ. Bạn tuyệt đối không phỏng đoán về những tệp mà mình chưa được xem, và không bao giờ bảo người dùng nạp lại (reload) cấu hình mà không chạy `nginx -t` trước.

## Các tệp trong kỹ năng này

- `scripts/nginx_review.py` - phân tích cú pháp các tệp cấu hình nginx thành từng khối (blocks) và in ra các phát hiện kèm theo mức độ nghiêm trọng, số dòng và cách khắc phục (chỉ dùng thư viện chuẩn Python 3)
- `references/hardening-checklist.md` - mọi mục kiểm tra mà kịch bản thực thi, lý do tại sao nó quan trọng và cách khắc phục, cộng thêm các mục cần kiểm tra thủ công
- `references/tls-and-headers.md` - thiết lập giao thức TLS và bộ mã hóa (ciphers), lộ trình triển khai HSTS, các tiêu đề bảo mật và cơ chế hoạt động của tính kế thừa `add_header`
- `templates/review-report.md` - bản báo cáo bạn bàn giao lại: tóm tắt, bảng phát hiện lỗi, phần so sánh khác biệt (diff) của cấu hình, các câu lệnh kiểm tra
- `examples/shop-site.conf` - một cấu hình cửa hàng nhỏ từ năm 2019 với 18 phát hiện lỗi (3 lỗi mức HIGH)
- `examples/shop-site-fixed.conf` - cùng trang web đó sau khi đã áp dụng các bản sửa lỗi (không còn phát hiện lỗi nào ngoại trừ ghi chú về lệnh include)

## Quy trình làm việc

### 1. Thu thập toàn bộ cấu hình
Yêu cầu kết quả đầu ra của lệnh `sudo nginx -T` (lệnh này in ra tệp cấu hình chính và mọi tệp được đính kèm qua lệnh include), hoặc yêu cầu tệp nginx.conf cộng với các tệp liên quan trong thư mục sites-enabled. Hỏi xem trang web đó làm gì (phục vụ tệp tĩnh, reverse proxy, PHP), những đường dẫn nào bắt buộc phải giữ công khai, phiên bản nginx (`nginx -v`) và liệu có CDN hay bộ cân bằng tải (load balancer) nào đứng trước xử lý chấm dứt TLS (terminate TLS) hay không. Nhắc người dùng xóa các khóa bí mật (private keys) hoặc thông tin nhạy cảm (secrets) trước khi dán nội dung vào; giữ nguyên đường dẫn chứng chỉ thì không sao.

### 2. Chạy công cụ kiểm tra
```
python3 scripts/nginx_review.py examples/shop-site.conf
python3 scripts/nginx_review.py /etc/nginx/nginx.conf /etc/nginx/sites-enabled/shop --json
python3 scripts/nginx_review.py --self-test
```
Mã thoát (exit code) 1 có nghĩa là có các phát hiện mức HIGH, 2 có nghĩa là không thể đọc hoặc phân tích cú pháp tệp (thông báo lỗi sẽ chỉ rõ dòng gặp sự cố).

### 3. Xác nhận và bổ sung các bước kiểm tra thủ công
Đọc từng phát hiện lỗi trong mối tương quan với bối cảnh của người dùng: việc có một CDN đứng trước sẽ làm thay đổi các phát hiện về TLS, một tuyến quản trị chỉ dùng nội bộ có thể đã được bảo vệ bởi tường lửa, một câu lệnh `return 404` có thể đã ẩn đường dẫn đó đi rồi. Sau đó, rà soát qua các mục kiểm tra thủ công trong `references/hardening-checklist.md` (các tệp được include, xác minh TLS ở phía upstream, các khối `if`, các bộ xử lý PHP, quyền truy cập tệp). Loại bỏ các cảnh báo dương tính giả (false positives) và giải thích rõ lý do.

### 4. Bàn giao kết quả
Điền vào `templates/review-report.md`: ưu tiên các mục mức HIGH lên đầu, đưa ra bản so sánh khác biệt (diff) tối giản của cấu hình, cùng các bước kiểm thử. Luôn giải thích quy tắc kế thừa của `add_header` bất cứ khi nào lỗi ADD_HEADER_INHERITANCE xuất hiện, bởi vì quy tắc này gây bất ngờ cho hầu hết mọi người. Khi mới bắt đầu áp dụng HSTS cho một trang web chưa từng dùng nó trước đây, hãy đề xuất mức `max-age` ngắn trước.

## Kết quả ví dụ
Đối với `examples/shop-site.conf`: 3 lỗi mức HIGH (bật TLS 1.0/1.1, dùng các bộ mã hóa RC4 và 3DES, khối `location /images` dùng `alias .../images/` cho phép tấn công duyệt đường dẫn qua `/images../`), 6 lỗi mức MEDIUM (kích thước phần thân yêu cầu không giới hạn, cổng 80 trực tiếp trả về nội dung thay vì chuyển hướng, bật autoindex trên `/downloads/`, đường dẫn `/api/` làm mất HSTS và nosniff do tự đặt `add_header` riêng, đường dẫn `/admin/` không được bảo vệ, dùng biến `$uri` trong lệnh chuyển hướng), 8 lỗi mức LOW (server_tokens, các tệp ẩn bắt đầu bằng dấu chấm - dotfiles, thiếu CSP/Referrer-Policy/X-Frame-Options, các tiêu đề proxy, không giới hạn tần suất truy cập trên `/api/auth/login`) và 1 mục mức INFO. Tệp `examples/shop-site-fixed.conf` thể hiện các bản sửa lỗi và vượt qua bước kiểm tra `nginx -t`.

## Quy tắc

- Trích dẫn chính xác dòng bị lỗi và nội dung thay thế chính xác; giữ cho các thay đổi ở mức tối thiểu và bảo toàn nguyên vẹn chức năng định tuyến.
- Luôn kết thúc bằng câu lệnh `sudo nginx -t && sudo systemctl reload nginx` cùng một lệnh kiểm tra bằng curl; tuyệt đối không đề xuất khởi động lại (restart) làm đứt kết nối khi chỉ cần nạp lại (reload) là đủ.
- Không tự bịa ra các chỉ thị (directives): hãy đối chiếu tên chỉ thị với tài liệu chính thức của nginx tương ứng với phiên bản của người dùng (ví dụ: `http2 on;` yêu cầu phiên bản 1.25.1 trở lên, các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`).
- Tuyệt đối không yêu cầu hoặc lặp lại các khóa bí mật (private keys), mật khẩu hoặc mã băm htpasswd.
- Hãy coi việc áp dụng các tiêu đề bảo mật như CSP là một lộ trình triển khai từng bước: đề xuất dùng `Content-Security-Policy-Report-Only` trước đối với các ứng dụng phức tạp.
FILE:references/hardening-checklist.md
# Danh Sách Kiểm Tra Gia Cố Bảo Mật Nginx

Mọi mã lỗi mà kịch bản in ra, lý do tại sao nó quan trọng và cách khắc phục. Mức độ nghiêm trọng dưới đây là mức mặc định; hãy nâng lên hoặc hạ xuống tùy theo bối cảnh thực tế.

## HIGH (Cao)

### OLD_TLS_PROTOCOL
Chỉ thị `ssl_protocols` có liệt kê SSLv3, TLSv1 hoặc TLSv1.1. Các giao thức này đã bị loại bỏ (RFC 8996), không đạt chuẩn PCI DSS và cho phép thực hiện các cuộc tấn công hạ cấp (downgrade attacks). Cách khắc phục: đặt `ssl_protocols TLSv1.2 TLSv1.3;` (đây là giá trị mặc định kể từ phiên bản nginx 1.23.4, vì vậy việc xóa dòng này đi cũng có tác dụng tương tự trên các phiên bản mới). Chỉ những trình khách (clients) quá cũ (như Android 4.3, IE trên Windows 7 chưa cập nhật) mới bị mất quyền truy cập.

### WEAK_CIPHERS
Chỉ thị `ssl_ciphers` cho phép các bộ mã hóa RC4, DES/3DES (lỗ hổng SWEET32), MD5, NULL, EXPORT hoặc ẩn danh (aNULL/ADH). Các mục bắt đầu bằng dấu `!` hoặc `-` là các quy tắc loại trừ nên hoàn toàn hợp lệ. Cách khắc phục: sử dụng danh sách "intermediate" (trung cấp) của Mozilla từ trang ssl-config.mozilla.org hoặc xóa bỏ dòng `ssl_ciphers` để sử dụng cấu hình mặc định của OpenSSL, đồng thời đặt `ssl_prefer_server_ciphers off;` khi chỉ còn lại các bộ mã hóa AEAD hiện đại.

### NO_CERTIFICATE
Một khối server lắng nghe với tham số `ssl` nhưng lại không thiết lập `ssl_certificate` bên trong nó hoặc trong khối http. Khi đó nginx sẽ từ chối khởi động hoặc làm thất bại quá trình bắt tay (handshake). Hãy thêm `ssl_certificate` (chuỗi chứng chỉ đầy đủ - full chain) và `ssl_certificate_key`.

### ALIAS_TRAVERSAL (lệch dấu gạch chéo - off-by-slash)
`location /images { alias /var/www/media/images/; }`: một yêu cầu truy cập tới `/images../secret.txt` sẽ được ánh xạ thành `/var/www/media/images/../secret.txt`, khiến cho các tệp nằm ở thư mục cha ngay phía trên đường dẫn alias có thể bị đọc trái phép (một phát hiện rất phổ biến trong nhiều cuộc kiểm toán bảo mật). Cách khắc phục: đảm bảo cả đường dẫn trong location và đường dẫn trong alias đều kết thúc bằng dấu gạch chéo, tức là `location /images/ { alias /var/www/media/images/; }`, hoặc sử dụng chỉ thị `root` khi đường dẫn URL trùng khớp với tên thư mục trên đĩa.

## MEDIUM (Trung bình)

### HTTP_NO_REDIRECT
Một khối server ở cổng 80 có cùng `server_name` với một khối server HTTPS nhưng lại trực tiếp phục vụ các trang web thay vì chuyển hướng người dùng. Khi đó người dùng và dữ liệu cookie vẫn nằm trên kết nối HTTP thuần không mã hóa. Cách khắc phục:
```
server {
    listen 80;
    server_name shop.example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}
```

### NO_HSTS
Không có tiêu đề `Strict-Transport-Security` trên máy chủ HTTPS, dẫn đến việc yêu cầu đầu tiên của một phiên truy cập có thể bị tấn công hạ cấp xuống HTTP. Xem tệp `tls-and-headers.md` để biết lộ trình triển khai an toàn.

### ADD_HEADER_INHERITANCE
nginx chỉ kế thừa các chỉ thị `add_header` từ cấp cha khi cấp hiện tại hoàn toàn không có bất kỳ chỉ thị `add_header` nào. Chỉ cần một dòng `add_header Cache-Control no-store;` bên trong khối `location /api/` cũng sẽ làm mất sạch mọi tiêu đề bảo mật được khai báo ở khối `server` đối với location đó. Cách khắc phục: lặp lại đầy đủ các tiêu đề bảo mật bên trong location đó, hoặc tách chúng ra một tệp đoạn mã riêng (`include snippets/security-headers.conf;`) và include tệp này vào mọi khối có sử dụng `add_header` riêng. Trên phiên bản nginx 1.29.3 trở lên, việc đặt `add_header_inherit merge;` trong khối http sẽ giúp các khối con cộng gộp thêm vào các tiêu đề của khối cha thay vì ghi đè toàn bộ. Lưu ý rằng `proxy_set_header`, `fastcgi_param` và `proxy_hide_header` cũng tuân theo đúng quy tắc kế thừa này.

### AUTOINDEX_ON
Tính năng liệt kê thư mục làm hiển thị tên của mọi tệp tin, bao gồm cả các bản sao lưu (backups) và các tệp kết xuất dữ liệu (exports) mà không ai chèn liên kết tới. Hãy tắt tính năng này đi hoặc thiết lập cơ chế bảo vệ cho location đó.

### ADMIN_OPEN
Các đường dẫn như /admin, /phpmyadmin, /actuator, /server-status, /nginx_status hoặc /metrics không được thiết lập quy tắc allow/deny, auth_basic hoặc auth_request. Cách khắc phục: thêm `allow 10.20.0.0/16; deny all;` (dải mạng văn phòng hoặc VPN), hoặc dùng `auth_request` trỏ tới dịch vụ đăng nhập một lần (SSO). Chỉ sử dụng xác thực cơ bản (Basic auth) qua kết nối HTTPS.

### UNLIMITED_BODY
Thiết lập `client_max_body_size 0` sẽ vô hiệu hóa giới hạn kích thước yêu cầu gửi lên, khiến kẻ tấn công dễ dàng làm cạn kiệt dung lượng đĩa và bộ nhớ. Hãy giữ mức mặc định 1m hoặc đặt một mức giới hạn thực tế trong khối http, và chỉ nâng giới hạn này lên ở riêng location dùng để tải tệp lên (upload).

### URI_IN_REDIRECT
Biến `$uri` đã được chuẩn hóa và giải mã URL (URL-decoded), vì vậy chuỗi `%0d%0a` sẽ biến thành ký tự xuống dòng thực sự và kẻ tấn công có thể chèn các tiêu đề (`Set-Cookie`) vào phản hồi chuyển hướng (HTTP response splitting / header injection). Hãy sử dụng biến `$request_uri` (dòng yêu cầu thô, vẫn còn nguyên mã hóa URL) trong các lệnh `return`, đích đến của `rewrite` và `add_header`.

## LOW (Thấp)

### SERVER_TOKENS
Số phiên bản xuất hiện trong tiêu đề `Server` và trên các trang báo lỗi, giúp kẻ tấn công dễ dàng đối chiếu với các lỗ hổng CVE đã biết. Hãy đặt `server_tokens off;` trong khối http.

### MISSING_SECURITY_HEADERS
Trên các máy chủ HTTPS: thiếu `X-Content-Type-Options nosniff`, `X-Frame-Options` (hoặc CSP `frame-ancestors`), `Content-Security-Policy`, `Referrer-Policy`. Hãy thêm chúng kèm theo tham số `always` để các tiêu đề này cũng được gửi đi trong cả các phản hồi lỗi 4xx/5xx.

### SHORT_HSTS
Giá trị `max-age` của HSTS ngắn hơn 180 ngày. Mức này hoàn toàn ổn trong giai đoạn mới triển khai thử nghiệm; hãy nâng nó lên mức một năm sau khi đã chạy ổn định.

### DOTFILES_EXPOSED
Máy chủ phục vụ các tệp tĩnh nhưng lại không có quy tắc chặn các tệp ẩn bắt đầu bằng dấu chấm, dẫn đến việc các tệp như `/.git/config`, `/.env` hoặc `/.htpasswd` có thể bị tải xuống nếu chúng tồn tại bên dưới thư mục gốc của web (web root). Cách khắc phục: thêm `location ~ /\.(?!well-known) { deny all; }` (vẫn giữ cho các yêu cầu xác thực chứng chỉ ACME hoạt động bình thường).

### NO_RATE_LIMIT
Một đường dẫn đăng nhập, xác thực hoặc cấp phát token không được áp dụng `limit_req`. Hãy khai báo một vùng nhớ (zone) trong khối http và áp dụng nó vào location tương ứng; trả về mã lỗi 429 bằng chỉ thị `limit_req_status 429;`.

### PROXY_HEADERS
Một khối location dùng `proxy_pass` nhưng không gửi kèm các tiêu đề Host, X-Forwarded-For và X-Forwarded-Proto, khiến ứng dụng phía sau ghi nhật ký nhầm địa chỉ IP của máy chủ proxy, tạo sai các URL chuyển hướng và có thể hiểu nhầm các yêu cầu HTTPS là HTTP. Hãy nhớ quy tắc kế thừa: chỉ cần một dòng `proxy_set_header` trong khối location cũng sẽ thay thế toàn bộ các dòng `proxy_set_header` được kế thừa từ cấp trên.

## INFO (Thông tin)

### INCLUDE_NOT_FOLLOWED
Kịch bản chỉ đọc những tệp mà bạn truyền trực tiếp vào. Hãy yêu cầu kết quả của lệnh `nginx -T` hoặc các tệp được đính kèm qua lệnh include.

## Kiểm tra thủ công (không tự động hóa)
- `proxy_pass https://...` trỏ tới một máy chủ upstream ngoài internet mà không bật `proxy_ssl_verify on;` và `proxy_ssl_trusted_certificate`.
- PHP: khối `location ~ \.php$` bắt buộc phải sử dụng `try_files $uri =404;` (hoặc `fastcgi_split_path_info` kèm theo bước kiểm tra) để các tệp hình ảnh do người dùng tải lên không thể bị thực thi như mã PHP; các thư mục chứa tệp tải lên tuyệt đối không bao giờ được chuyển tiếp sang bộ xử lý PHP.
- Dùng câu lệnh `if` bên trong khối location với bất kỳ chỉ thị nào khác ngoài `return` hoặc `rewrite ... last` (nguyên tắc nổi tiếng "if is evil").
- Các khối `default_server`: một yêu cầu có tiêu đề Host lạ không xác định nên nhận về `return 444;` kèm chứng chỉ tự ký hoặc bật `ssl_reject_handshake on;` (từ bản 1.19.4 trở lên) thay vì rơi vào trang web đầu tiên trong cấu hình.
- Quyền truy cập tệp (Permissions): các tiến trình worker của nginx không được phép có quyền ghi vào thư mục gốc của web (web root); các khóa bí mật (private keys) phải đặt `chmod 600` và thuộc quyền sở hữu của root.
- Tiêu đề kích thước lớn / các trình khách kết nối chậm (slow clients): cấu hình `client_body_timeout`, `client_header_timeout`, `large_client_header_buffers` cho các API công khai đang chịu tấn công.
- Ghi nhật ký (Logging): nhật ký truy cập (access logs) không được ghi lại các token nằm trong chuỗi truy vấn (query strings); hãy sử dụng một định dạng `log_format` tùy chỉnh nếu cần.
FILE:references/tls-and-headers.md
# TLS và Các Tiêu Đề Bảo Mật

## Thiết lập TLS vượt qua hầu hết các đợt kiểm toán (nginx 1.25+ với OpenSSL 1.1.1 hoặc 3.x)
```
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
```
Đây là cấu hình thuộc hồ sơ "intermediate" (trung cấp) của Mozilla. Các bộ mã hóa của TLS 1.3 không được đặt thông qua chỉ thị `ssl_ciphers` (OpenSSL tự xử lý chúng). Nếu giữ lại các bộ mã hóa DHE, hãy cung cấp thêm `ssl_dhparam` với độ dài tối thiểu 2048 bit hoặc loại bỏ các mục DHE khỏi danh sách.

Tính năng OCSP stapling (`ssl_stapling on; ssl_stapling_verify on;`) là tùy chọn; Let's Encrypt đã ngừng hỗ trợ OCSP vào năm 2025, vì vậy đừng thêm cấu hình này đối với các chứng chỉ của Let's Encrypt.

HTTP/2: dùng `http2 on;` bên trong khối server trên nginx 1.25.1 trở lên; các phiên bản cũ hơn sử dụng `listen 443 ssl http2;`.

## Lộ trình triển khai HSTS
1. Đảm bảo mọi tên miền phụ (subdomain) đều hoạt động tốt qua giao thức HTTPS.
2. Bắt đầu với thời hạn ngắn: `add_header Strict-Transport-Security "max-age=300" always;`
3. Sau một tuần chạy ổn định không phát sinh sự cố: nâng lên `max-age=31536000; includeSubDomains`.
4. Chỉ thêm tham số `preload` và gửi đăng ký lên trang hstspreload.org khi bạn đã hoàn toàn chắc chắn; việc gỡ bỏ khỏi danh sách preload sẽ mất đến hàng tháng trời.
Tiêu đề HSTS gửi trên các phản hồi HTTP thuần sẽ bị trình duyệt bỏ qua, vì vậy hãy đặt nó trên khối server cổng 443.

## Các tiêu đề bảo mật (Security headers)
| Tiêu đề | Giá trị điển hình | Ghi chú |
|---|---|---|
| X-Content-Type-Options | `nosniff` | Ngăn chặn hành vi tự động đoán kiểu MIME đối với các tệp tải lên |
| X-Frame-Options | `DENY` hoặc `SAMEORIGIN` | Chống tấn công Clickjacking; chỉ thị `frame-ancestors` của CSP là giải pháp tương đương hiện đại hơn |
| Content-Security-Policy | `default-src 'self'` và các quy tắc khác | Đặc thù theo từng ứng dụng; hãy triển khai thử nghiệm trước bằng `Content-Security-Policy-Report-Only` |
| Referrer-Policy | `strict-origin-when-cross-origin` | Giữ cho các đường dẫn và chuỗi truy vấn không bị lọt vào nhật ký của các trang web khác |
| Permissions-Policy | `camera=(), microphone=(), geolocation=()` | Tùy chọn |
Hãy thêm tham số `always` để các tiêu đề này luôn được gửi kèm trong cả những phản hồi báo lỗi.

## Quy tắc kế thừa của add_header
```
server {
    add_header X-Content-Type-Options nosniff always;   # A
    location / { }                                      # nhận được A
    location /api/ {
        add_header Cache-Control no-store;              # CHỈ nhận được tiêu đề này, A bị mất
    }
}
```
Một khối chỉ kế thừa các chỉ thị `add_header` từ khối cha của nó nếu bản thân nó không tự định nghĩa bất kỳ chỉ thị `add_header` nào. Hãy gom các tiêu đề dùng chung vào một tệp snippet và include tệp đó vào mọi khối có khai báo thêm tiêu đề riêng. Phiên bản nginx 1.29.3 (tháng 10 năm 2025) đã bổ sung chỉ thị `add_header_inherit merge;`, giúp các khối lồng bên trong cộng gộp thêm vào danh sách tiêu đề được kế thừa; hãy kiểm tra phiên bản bằng `nginx -v` trước khi đề xuất dùng nó (Debian 13 đi kèm bản 1.26).

## Kiểm thử
```
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://shop.example.com/ | grep -iE 'strict|x-content|x-frame|content-security|referrer|server:'
curl -sI http://shop.example.com/page?x=1        # kỳ vọng mã 301 chuyển tới https://shop.example.com/page?x=1
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/.git/config   # kỳ vọng mã 403 hoặc 404
curl -s -o /dev/null -w '%{http_code}\n' https://shop.example.com/images../      # kỳ vọng mã 404 sau khi sửa lỗi alias
openssl s_client -connect shop.example.com:443 -tls1_1 </dev/null   # kỳ vọng quá trình bắt tay thất bại
```
Để kiểm tra toàn diện từ bên ngoài: sử dụng công cụ kiểm tra máy chủ của SSL Labs hoặc chạy `testssl.sh shop.example.com`.
FILE:templates/review-report.md
# Báo cáo rà soát bảo mật Nginx: <tên trang web hoặc máy chủ>

**Phiên bản:** <nginx 1.26.3, OpenSSL 3.x>   **Vai trò:** <trang web tĩnh / reverse proxy trỏ tới Node trên cổng :3000 / PHP-FPM>
**Các tệp đã rà soát:** <đầu ra lệnh nginx -T, 2 tệp>   **TLS được chấm dứt bởi:** <nginx / CDN / bộ cân bằng tải>
**Tóm tắt:** <một câu: vấn đề nghiêm trọng nhất và bản sửa lỗi sẽ thay đổi điều gì>

## Các phát hiện lỗi (Findings)
| # | Mức độ | Mã lỗi | Tệp:dòng | Vấn đề | Cách khắc phục |
|---|---|---|---|---|---|
| 1 | HIGH | <ALIAS_TRAVERSAL> | <nginx.conf:51> | <location /images cho phép truy cập /images../> | <location /images/ { alias .../images/; }> |
| 2 | <MEDIUM> | <...> | <...> | <...> | <...> |

## Các mục giữ nguyên không thay đổi (kèm lý do)
- <ADMIN_OPEN trên /admin/: đã được giới hạn bởi tường lửa đám mây chỉ cho phép mạng VPN; có thêm deny all nữa hay không? có/không>

## Các thay đổi trong cấu hình
```diff
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+ ssl_protocols TLSv1.2 TLSv1.3;
```

## Áp dụng và kiểm thử
1. Sao lưu: `sudo cp -a /etc/nginx /etc/nginx.bak-<ngày>`
2. Chỉnh sửa tệp, sau đó chạy `sudo nginx -t && sudo systemctl reload nginx`
3. Các bước kiểm tra:
   - `curl -sI https://<host>/` hiển thị đầy đủ các tiêu đề bảo mật
   - `curl -sI http://<host>/x?y=1` trả về mã 301 chuyển tới cùng đường dẫn đó trên giao thức https
   - <các lệnh kiểm tra duyệt đường dẫn / tệp ẩn bắt đầu bằng dấu chấm / trang quản trị tương ứng với các phát hiện lỗi>
4. Khôi phục (Roll back): phục hồi lại bản sao lưu và nạp lại (reload) cấu hình.

## Các việc cần theo dõi tiếp (Follow-ups)
- <HSTS: nâng giá trị max-age lên 31536000 sau ngày <ngày>>
- <CSP: chuyển từ chế độ Report-Only sang chế độ bắt buộc thực thi (enforcing) sau khi đã rà soát các báo cáo>
FILE:examples/shop-site.conf
# /etc/nginx/nginx.conf for shop.example.com (single-file setup)
# A small online shop: static storefront, product images, a Node.js API on :3000
# and a legacy admin panel. Written in 2019, certificates renewed by certbot.

user www-data;
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    sendfile on;
    keepalive_timeout 65;
    client_max_body_size 0;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers "HIGH:RC4-SHA:DES-CBC3-SHA:!aNULL";
    ssl_prefer_server_ciphers on;

    server {
        listen 80;
        server_name shop.example.com www.shop.example.com;
        root /var/www/shop;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }
    }

    server {
        listen 443 ssl;
        http2 on;
        server_name shop.example.com www.shop.example.com;

        ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

        add_header Strict-Transport-Security "max-age=31536000" always;
        add_header X-Content-Type-Options nosniff always;

        root /var/www/shop;
        index index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location /images {
            alias /var/www/shop-media/images/;
        }

        location /downloads/ {
            alias /var/www/shop-media/downloads/;
            autoindex on;
        }

        location /api/ {
            add_header Cache-Control "no-store";
            proxy_pass http://127.0.0.1:3000;
        }

        location /api/auth/login {
            proxy_pass http://127.0.0.1:3000;
            proxy_set_header Host $host;
        }

        location /admin/ {
            proxy_pass http://127.0.0.1:8080/;
        }

        location /old-blog {
            return 301 https://blog.example.com$uri;
        }
    }
}
FILE:examples/shop-site-fixed.conf
# shop.example.com after the review: same routes, hardened.
# Shared security headers live in one snippet so locations with their own
# add_header can include it again (add_header is not inherited into a block
# that sets any add_header of its own).

user www-data;
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    sendfile on;
    keepalive_timeout 65;
    server_tokens off;
    client_max_body_size 10m;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;

    limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;

    server {
        listen 80;
        server_name shop.example.com www.shop.example.com;

        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
        }

        location / {
            return 301 https://$host$request_uri;
        }
    }

    server {
        listen 443 ssl;
        http2 on;
        server_name shop.example.com www.shop.example.com;

        ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

        # same lines as /etc/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 X-Frame-Options SAMEORIGIN always;
        add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;

        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        root /var/www/shop;
        index index.html;

        location ~ /\.(?!well-known) {
            deny all;
        }

        location / {
            try_files $uri $uri/ /index.html;
        }

        location /images/ {
            alias /var/www/shop-media/images/;
        }

        location /downloads/ {
            alias /var/www/shop-media/downloads/;
        }

        location /api/ {
            add_header Cache-Control "no-store" always;
            add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
            add_header X-Content-Type-Options nosniff always;
            add_header X-Frame-Options SAMEORIGIN always;
            add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; frame-ancestors 'self'" always;
            add_header Referrer-Policy strict-origin-when-cross-origin always;
            proxy_pass http://127.0.0.1:3000;
        }

        location /api/auth/login {
            limit_req zone=login burst=5 nodelay;
            proxy_pass http://127.0.0.1:3000;
        }

        location /admin/ {
            allow 10.20.0.0/16;
            deny all;
            proxy_pass http://127.0.0.1:8080/;
        }

        location /old-blog {
            return 301 https://blog.example.com$request_uri;
        }
    }
}
FILE:scripts/nginx_review.py
#!/usr/bin/env python3
"""Nginx config security reviewer (Python 3 standard library only).

Parses nginx.conf / sites-available files into a block tree and flags common
security and hardening problems: old TLS protocols, weak ciphers, missing HSTS
and security headers, the add_header inheritance trap, alias off-by-slash path
traversal, autoindex, unlimited body size, $uri in redirects (CRLF injection),
plain-HTTP servers without a redirect, open admin paths, login paths without
rate limiting, exposed dotfiles and proxies without forwarding headers.

Usage:
  python3 nginx_review.py site.conf [more.conf ...] [--json]
  python3 nginx_review.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings, 2 = unreadable/unparseable input.
"""
import argparse, json, re, sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
OLD_PROTOCOLS = {"SSLv2", "SSLv3", "TLSv1", "TLSv1.1"}
WEAK_CIPHER_WORDS = ["RC4", "DES", "3DES", "MD5", "NULL", "EXPORT", "EXP-", "aNULL", "eNULL", "ADH", "AECDH"]
SECURITY_HEADERS = {
    "x-content-type-options": "X-Content-Type-Options nosniff",
    "x-frame-options": "X-Frame-Options DENY or SAMEORIGIN (or CSP frame-ancestors)",
    "content-security-policy": "Content-Security-Policy",
    "referrer-policy": "Referrer-Policy strict-origin-when-cross-origin",
}
LOGIN_RE = re.compile(r"login|signin|sign-in|auth|token|password|wp-login", re.I)
ADMIN_RE = re.compile(r"admin|phpmyadmin|actuator|server-status|nginx_status|/metrics|wp-admin", re.I)


class ParseError(Exception):
    pass


class Node:
    def __init__(self, name, args, line, children=None):
        self.name, self.args, self.line, self.children = name, args, line, children

    def get(self, name):
        return [c for c in (self.children or []) if c.name == name]

    def first(self, name):
        found = self.get(name)
        return found[0] if found else None

    def walk(self):
        for c in self.children or []:
            yield c
            if c.children is not None:
                yield from c.walk()


def tokenize(text):
    tokens, i, line, n = [], 0, 1, len(text)
    while i < n:
        ch = text[i]
        if ch == "\n":
            line += 1; i += 1
        elif ch.isspace():
            i += 1
        elif ch == "#":
            while i < n and text[i] != "\n":
                i += 1
        elif ch in "{};":
            tokens.append((ch, line, True)); i += 1
        elif ch in "\"'":
            q, j, buf = ch, i + 1, []
            while j < n and text[j] != q:
                if text[j] == "\\" and j + 1 < n:
                    buf.append(text[j + 1]); j += 2; continue
                if text[j] == "\n":
                    line += 1
                buf.append(text[j]); j += 1
            if j >= n:
                raise ParseError(f"line {line}: unterminated quote")
            tokens.append(("".join(buf), line, False)); i = j + 1
        else:
            j = i
            while j < n and not text[j].isspace() and text[j] not in "{};":
                if text[j] == "$" and j + 1 < n and text[j + 1] == "{":  # var
                    k = text.find("}", j)
                    j = k + 1 if k != -1 else n
                    continue
                j += 1
            tokens.append((text[i:j], line, False)); i = j
    return tokens


def parse(text):
    tokens = tokenize(text)
    root = Node("main", [], 0, [])
    stack, cur = [root], []
    for tok, line, special in tokens:
        if special and tok == ";":
            if not cur:
                continue
            stack[-1].children.append(Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1]))
            cur = []
        elif special and tok == "{":
            if not cur:
                raise ParseError(f"line {line}: '{{' without a directive name")
            node = Node(cur[0][0], [t for t, _ in cur[1:]], cur[0][1], [])
            stack[-1].children.append(node); stack.append(node); cur = []
        elif special and tok == "}":
            if cur:
                raise ParseError(f"line {line}: missing ';' before '}}'")
            if len(stack) == 1:
                raise ParseError(f"line {line}: unexpected '}}'")
            stack.pop()
        else:
            cur.append((tok, line))
    if cur:
        raise ParseError(f"line {cur[0][1]}: directive '{cur[0][0]}' not terminated with ';'")
    if len(stack) != 1:
        raise ParseError(f"block '{stack[-1].name}' opened on line {stack[-1].line} is not closed")
    return root


def headers_of(node):
    return {d.args[0].lower(): d for d in node.get("add_header") if d.args}


def is_tls(server):
    for l in server.get("listen"):
        if "ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args):
            return True
    s = server.first("ssl")
    return bool(s and s.args and s.args[0] == "on")


def listens_plain(server):
    ls = server.get("listen")
    if not ls:
        return True  # default listen *:80
    for l in ls:
        if not ("ssl" in l.args or "quic" in l.args or any(a.endswith(":443") or a == "443" for a in l.args)):
            return True
    return False


def names(server):
    out = []
    for d in server.get("server_name"):
        out.extend(d.args)
    return set(out) or {"_"}


def inherited(scopes, name):
    """Value of a directive as nginx inherits it: innermost scope that sets it."""
    for s in reversed(scopes):
        d = s.first(name)
        if d is not None:
            return d
    return None


def effective_headers(scopes):
    for s in reversed(scopes):
        h = headers_of(s)
        if h:
            return h
    return {}


def review(text, fname="nginx.conf"):
    root = parse(text)
    http = root.first("http")
    top = http if http is not None else root
    base = [root] + ([http] if http is not None else [])
    servers = [s for s in top.get("server")]
    findings = []

    def add(sev, code, line, msg, fix):
        findings.append({"severity": sev, "code": code, "file": fname, "line": line, "message": msg, "fix": fix})

    for d in root.walk():
        if d.name == "include":
            add("INFO", "INCLUDE_NOT_FOLLOWED", d.line, f"include {' '.join(d.args)} is not read by this checker",
                "Review the included files too (pass them as extra arguments) or paste `nginx -T` output.")

    tokens = [d for d in root.walk() if d.name == "server_tokens"]
    if not any(d.args and d.args[0] == "off" for d in tokens):
        add("LOW", "SERVER_TOKENS", (http or root).line, "server_tokens is not off, so error pages and the Server header show the nginx version",
            "Add `server_tokens off;` in the http block.")

    tls_names = set()
    for s in servers:
        if is_tls(s):
            tls_names |= names(s)

    for s in servers:
        scopes = base + [s]
        label = " ".join(sorted(names(s)))
        tls = is_tls(s)
        if tls:
            if inherited(scopes, "ssl_certificate") is None:
                add("HIGH", "NO_CERTIFICATE", s.line, f"TLS server {label} has no ssl_certificate in scope",
                    "Add ssl_certificate and ssl_certificate_key (fullchain + key).")
            proto = inherited(scopes, "ssl_protocols")
            if proto is not None:
                old = [p for p in proto.args if p in OLD_PROTOCOLS]
                if old:
                    add("HIGH", "OLD_TLS_PROTOCOL", proto.line, f"{label}: ssl_protocols enables {', '.join(old)}",
                        "Use `ssl_protocols TLSv1.2 TLSv1.3;` (SSLv3/TLS 1.0/1.1 are deprecated, RFC 8996).")
            ciph = inherited(scopes, "ssl_ciphers")
            if ciph is not None and ciph.args:
                parts = re.split(r"[:\s]+", " ".join(ciph.args))
                weak = sorted({w for p in parts if p and not p.startswith("!") and not p.startswith("-")
                               for w in WEAK_CIPHER_WORDS if w.lower() in p.lower()})
                if weak:
                    add("HIGH", "WEAK_CIPHERS", ciph.line, f"{label}: ssl_ciphers allows weak suites ({', '.join(weak)})",
                        "Use the Mozilla 'intermediate' cipher list, or remove ssl_ciphers to use the safe OpenSSL default.")
            hdrs = effective_headers(scopes)
            if "strict-transport-security" not in hdrs:
                add("MEDIUM", "NO_HSTS", s.line, f"TLS server {label} sends no Strict-Transport-Security header",
                    'Add `add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;` (start with a short max-age).')
            else:
                hsts = hdrs["strict-transport-security"]
                m = re.search(r"max-age=(\d+)", " ".join(hsts.args), re.I)
                if m and int(m.group(1)) < 15552000:
                    add("LOW", "SHORT_HSTS", hsts.line, f"{label}: HSTS max-age {m.group(1)} is under 180 days",
                        "Raise max-age to 31536000 once HTTPS works everywhere.")
            missing = [v for k, v in SECURITY_HEADERS.items() if k not in hdrs]
            if "x-frame-options" not in hdrs and "content-security-policy" in hdrs and \
                    "frame-ancestors" in " ".join(hdrs["content-security-policy"].args):
                missing = [v for v in missing if not v.startswith("X-Frame")]
            if missing:
                add("LOW", "MISSING_SECURITY_HEADERS", s.line, f"{label}: missing {'; '.join(missing)}",
                    "Add them with `always` in the server block (check the inheritance rule for locations).")
        elif listens_plain(s) and names(s) & tls_names:
            redirects = any(r.args and r.args[0] in ("301", "302", "307", "308") and len(r.args) > 1 and r.args[1].startswith("https://")
                            for r in [d for d in s.walk() if d.name == "return"])
            rewrites = any(len(r.args) > 1 and r.args[1].startswith("https://") for r in [d for d in s.walk() if d.name == "rewrite"])
            if not (redirects or rewrites):
                add("MEDIUM", "HTTP_NO_REDIRECT", s.line, f"plain-HTTP server {label} serves content instead of redirecting to HTTPS",
                    "Use `return 301 https://$host$request_uri;` in the port-80 server (keep only /.well-known/acme-challenge/ if needed).")

        server_hdrs = effective_headers(scopes)
        has_dot_rule = False
        locations = [d for d in s.walk() if d.name == "location"]
        for loc in locations:
            path = loc.args[-1] if loc.args else ""
            mod = loc.args[0] if len(loc.args) > 1 else ""
            if mod in ("~", "~*") and (r"/\." in path or path.startswith(r"\.") or r"(^|/)\." in path):
                if loc.get("deny") or any(r.args and r.args[0] in ("403", "404") for r in loc.get("return")):
                    has_dot_rule = True
            own = headers_of(loc)
            hi = inherited(scopes + [loc], "add_header_inherit")
            merge = bool(hi and hi.args and hi.args[0] == "merge")  # nginx 1.29.3+
            if own and server_hdrs and not merge:
                lost = sorted(set(server_hdrs) - set(own))
                if lost:
                    add("MEDIUM", "ADD_HEADER_INHERITANCE", loc.line,
                        f"location {path} has its own add_header, so it silently drops {', '.join(lost)} set at server/http level",
                        "Repeat those headers in this location, include a shared headers snippet, or on nginx 1.29.3+ set `add_header_inherit merge;`.")
            al = loc.first("alias")
            if al and al.args and mod in ("", "^~") and not path.endswith("/") and al.args[0].endswith("/"):
                add("HIGH", "ALIAS_TRAVERSAL", al.line,
                    f"location {path} (no trailing slash) with alias {al.args[0]} (trailing slash) allows {path}../ path traversal",
                    f"Make both end with a slash: `location {path}/ {{ alias {al.args[0]}; }}`.")
            ai = loc.first("autoindex")
            if ai and ai.args and ai.args[0] == "on":
                add("MEDIUM", "AUTOINDEX_ON", ai.line, f"autoindex on in location {path} lists directory contents",
                    "Turn it off, or restrict the location with allow/deny or auth_basic.")
            if ADMIN_RE.search(path):
                guarded = loc.get("deny") or loc.get("auth_basic") or loc.get("auth_request") or loc.get("satisfy") \
                    or any(r.args and r.args[0] in ("403", "404", "444") for r in loc.get("return"))
                if not guarded:
                    add("MEDIUM", "ADMIN_OPEN", loc.line, f"location {path} looks like an admin/status endpoint with no allow/deny or auth",
                        "Add `allow <office/VPN range>; deny all;` or auth_basic/auth_request, or remove the route.")
            if LOGIN_RE.search(path) and not inherited(scopes + [loc], "limit_req"):
                add("LOW", "NO_RATE_LIMIT", loc.line, f"location {path} looks like a login/auth endpoint without limit_req",
                    "Define `limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;` in http and `limit_req zone=login burst=5 nodelay;` here.")
            if loc.first("proxy_pass"):
                ph = set()
                for sc in reversed(scopes + [loc]):  # like add_header: innermost level that sets any wins
                    if sc.get("proxy_set_header"):
                        ph = {d.args[0].lower() for d in sc.get("proxy_set_header") if d.args}
                        break
                miss = [h for h in ("host", "x-forwarded-for", "x-forwarded-proto") if h not in ph]
                if miss:
                    add("LOW", "PROXY_HEADERS", loc.line, f"location {path} proxies without {', '.join(miss)}",
                        "Set proxy_set_header Host $host; X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; (note: any proxy_set_header in a location replaces inherited ones).")
        serves_files = inherited(scopes, "root") or any(
            (l.first("root") or l.first("alias")) and not (l.args and l.args[-1].startswith("/.well-known/")) for l in locations)
        if serves_files and not has_dot_rule:
            add("LOW", "DOTFILES_EXPOSED", s.line, f"{label}: no rule blocks hidden files such as /.git/ or /.env",
                r"Add `location ~ /\.(?!well-known) { deny all; }`.")

    for d in root.walk():
        if d.name == "client_max_body_size" and d.args and d.args[0] in ("0", "0m", "0k"):
            add("MEDIUM", "UNLIMITED_BODY", d.line, "client_max_body_size 0 disables the request body limit",
                "Set a real limit (e.g. 10m, or larger only in the upload location).")
        if d.name in ("return", "rewrite", "add_header") and any(re.search(r"\$uri\b|\$document_uri\b", a) for a in d.args):
            add("MEDIUM", "URI_IN_REDIRECT", d.line, f"{d.name} uses $uri (decoded, may contain CR/LF), which allows header injection",
                "Use $request_uri (raw, still encoded) in redirects and headers.")
    findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["line"]))
    return {"file": fname, "servers": [{"line": s.line, "names": sorted(names(s)), "tls": is_tls(s)} for s in servers],
            "findings": findings}


def to_markdown(results):
    out = ["# Nginx config security review", ""]
    allf = [f for r in results for f in r["findings"]]
    counts = {k: sum(1 for f in allf if f["severity"] == k) for k in SEV_ORDER}
    out.append("Findings: " + ", ".join(f"{v} {k}" for k, v in counts.items()))
    for r in results:
        out += ["", f"## {r['file']}", "", "| Server | Line | TLS |", "|---|---|---|"]
        for s in r["servers"]:
            out.append(f"| {' '.join(s['names'])} | {s['line']} | {'yes' if s['tls'] else 'no'} |")
        out += ["", "| Severity | Code | Line | Problem | Fix |", "|---|---|---|---|---|"]
        for f in r["findings"]:
            out.append(f"| {f['severity']} | {f['code']} | {f['line']} | {f['message']} | {f['fix']} |")
        if not r["findings"]:
            out.append("| - | - | - | no findings | - |")
    out += ["", "Always run `nginx -t` before reloading, and test with `curl -sI https://<host>/` afterwards."]
    return "\n".join(out)


def self_test():
    results = []

    def check(name, cond):
        results.append(cond)
        print(("PASS " if cond else "FAIL ") + name)

    def codes(conf):
        return [f["code"] for f in review(conf)["findings"]]

    good = """
    http {
      server_tokens off;
      limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
      server { listen 80; server_name a.test; return 301 https://$host$request_uri; }
      server {
        listen 443 ssl; http2 on; server_name a.test;
        ssl_certificate /c.pem; ssl_certificate_key /k.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options nosniff always;
        add_header Content-Security-Policy "default-src 'self'; frame-ancestors 'none'" always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        root /srv/www;
        location ~ /\\.(?!well-known) { deny all; }
        location /static/ { alias /srv/static/; }
        location /login { limit_req zone=login burst=5 nodelay; proxy_pass http://app;
          proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Forwarded-Proto $scheme; }
      }
    }"""
    check("clean config has no findings", codes(good) == [])
    check("comments and quotes parsed", parse('a "b c;" d; # x;\n e f;').children[0].args == ["b c;", "d"])
    check("var token kept", parse("set $x hosta;").children[0].args == ["$x", "hosta"])
    for bad, msg in [("server { listen 80;", "unclosed block"), ("a b", "missing ;"), ("}", "stray brace"), ('a "b;', "unterminated quote")]:
        try:
            parse(bad); check("parse error: " + msg, False)
        except ParseError:
            check("parse error: " + msg, True)
    check("SERVER_TOKENS when missing", "SERVER_TOKENS" in codes("http { server { listen 80; } }"))
    tls = "server { listen 443 ssl; server_name b.test; ssl_certificate /c; %s }"
    check("OLD_TLS_PROTOCOL", "OLD_TLS_PROTOCOL" in codes(tls % "ssl_protocols TLSv1 TLSv1.2;"))
    check("TLSv1.2 alone fine", "OLD_TLS_PROTOCOL" not in codes(tls % "ssl_protocols TLSv1.2;"))
    check("WEAK_CIPHERS", "WEAK_CIPHERS" in codes(tls % "ssl_ciphers 'HIGH:RC4-SHA:DES-CBC3-SHA';"))
    check("excluded !RC4 is fine", "WEAK_CIPHERS" not in codes(tls % "ssl_ciphers 'HIGH:!aNULL:!MD5:!RC4';"))
    check("NO_CERTIFICATE", "NO_CERTIFICATE" in codes("server { listen 443 ssl; }"))
    check("cert inherited from http", "NO_CERTIFICATE" not in codes("http { ssl_certificate /c; server { listen 443 ssl; } }"))
    check("NO_HSTS", "NO_HSTS" in codes(tls % ""))
    check("SHORT_HSTS", "SHORT_HSTS" in codes(tls % 'add_header Strict-Transport-Security "max-age=300";'))
    check("MISSING_SECURITY_HEADERS", "MISSING_SECURITY_HEADERS" in codes(tls % ""))
    inh = tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'
    r = review(inh)["findings"]
    check("ADD_HEADER_INHERITANCE names lost header",
          any(f["code"] == "ADD_HEADER_INHERITANCE" and "strict-transport-security" in f["message"] for f in r))
    check("headers inherited when location has none", "ADD_HEADER_INHERITANCE" not in codes(
        tls % 'add_header Strict-Transport-Security "max-age=31536000"; location /api/ { proxy_pass http://x; }'))
    check("add_header_inherit merge respected", "ADD_HEADER_INHERITANCE" not in codes(
        tls % 'add_header_inherit merge; add_header Strict-Transport-Security "max-age=31536000"; location /api/ { add_header Cache-Control no-store; }'))
    check("ALIAS_TRAVERSAL", "ALIAS_TRAVERSAL" in codes("server { location /img { alias /srv/img/; } }"))
    check("alias with matching slashes fine", "ALIAS_TRAVERSAL" not in codes("server { location /img/ { alias /srv/img/; } }"))
    check("regex location alias not flagged", "ALIAS_TRAVERSAL" not in codes(r"server { location ~ ^/img/(.*)$ { alias /srv/img/$1; } }"))
    check("AUTOINDEX_ON", "AUTOINDEX_ON" in codes("server { location /files/ { autoindex on; } }"))
    check("HTTP_NO_REDIRECT", "HTTP_NO_REDIRECT" in codes(
        "server { listen 80; server_name c.test; root /x; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
    check("no HTTP_NO_REDIRECT for unrelated name", "HTTP_NO_REDIRECT" not in codes(
        "server { listen 80; server_name d.test; } server { listen 443 ssl; server_name c.test; ssl_certificate /c; }"))
    check("ADMIN_OPEN", "ADMIN_OPEN" in codes("server { location /admin/ { proxy_pass http://x; } }"))
    check("admin with deny fine", "ADMIN_OPEN" not in codes("server { location /admin/ { allow 10.0.0.0/8; deny all; } }"))
    check("NO_RATE_LIMIT", "NO_RATE_LIMIT" in codes("server { location /api/login { proxy_pass http://x; } }"))
    check("rate limit inherited from server", "NO_RATE_LIMIT" not in codes(
        "server { limit_req zone=z; location /api/login { proxy_pass http://x; } }"))
    check("PROXY_HEADERS", "PROXY_HEADERS" in codes("server { location / { proxy_pass http://x; } }"))
    check("proxy_set_header in location replaces inherited", "PROXY_HEADERS" in codes(
        "server { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; "
        "proxy_set_header X-Forwarded-Proto $scheme; location / { proxy_set_header Upgrade $http_upgrade; proxy_pass http://x; } }"))
    check("DOTFILES_EXPOSED", "DOTFILES_EXPOSED" in codes("server { root /srv; }"))
    check("ACME-only port 80 server not flagged for dotfiles", "DOTFILES_EXPOSED" not in codes(
        "server { listen 80; location /.well-known/acme-challenge/ { root /var/www/certbot; } location / { return 301 https://$host$request_uri; } }"))
    check("UNLIMITED_BODY", "UNLIMITED_BODY" in codes("http { client_max_body_size 0; }"))
    check("URI_IN_REDIRECT", "URI_IN_REDIRECT" in codes("server { return 301 https://$host$uri; }"))
    check("$request_uri fine", "URI_IN_REDIRECT" not in codes("server { return 301 https://$host$request_uri; }"))
    check("INCLUDE_NOT_FOLLOWED", "INCLUDE_NOT_FOLLOWED" in codes("http { include /etc/nginx/conf.d/*.conf; }"))
    check("findings sorted HIGH first", review("server { listen 443 ssl; location /files/ { autoindex on; } }")["findings"][0]["severity"] == "HIGH")
    passed = sum(results)
    print(f"{passed}/{len(results)} passed")
    return 0 if passed == len(results) else 1


def main():
    ap = argparse.ArgumentParser(description="Nginx config security reviewer")
    ap.add_argument("files", nargs="*", help="nginx config files (nginx.conf, sites-available/*, or `nginx -T` output)")
    ap.add_argument("--json", action="store_true", help="print JSON instead of Markdown")
    ap.add_argument("--self-test", action="store_true")
    a = ap.parse_args()
    if a.self_test:
        return self_test()
    if not a.files:
        ap.error("give at least one config file, or --self-test")
    results = []
    for f in a.files:
        try:
            with open(f, encoding="utf-8") as fh:
                results.append(review(fh.read(), f))
        except (OSError, UnicodeDecodeError, ParseError) as e:
            print(f"error: {f}: {e}", file=sys.stderr)
            return 2
    print(json.dumps(results, indent=2) if a.json else to_markdown(results))
    return 1 if any(f["severity"] == "HIGH" for r in results for f in r["findings"]) else 0


if __name__ == "__main__":
    sys.exit(main())
SecurityPythonDevOps+2
F@f
0