SSD Nodes Learn 🎉 VPS từ $5.50/tháng
Hướng dẫn Matt ConnorBởi Matt Connor

Nginx lỗi 403 dù quyền file đúng: Cách sửa SELinux

Nginx trả về lỗi 403 dù phân quyền file hợp lệ là do SELinux chặn truy cập. Hướng dẫn đọc log denial, dùng semanage và restorecon để gán nhãn đúng mà không cần tắt enforcing.

Tại sao nginx trả về lỗi 403 trên một file có quyền truy cập hợp lệ

Việc nginx trả về lỗi 403 trên một file dù các bit quyền truy cập đã đúng gần như luôn là do SELinux (security-enhanced Linux) từ chối quyền đọc. SELinux kiểm tra một bộ quy tắc thứ hai sau khi các quyền thông thường đã được thông qua, và web server chỉ được phép đọc các file mang nhãn nội dung web. File của bạn đang mang một nhãn khác, vì vậy thao tác mở file thất bại và nginx không có dữ liệu để gửi.

Hãy kiểm tra nhãn, không chỉ kiểm tra mode:

ls -ldZ /data/www /data/www/index.html
drwxr-xr-x. root root unconfined_u:object_r:default_t:s0 /data/www
-rw-r--r--. root root unconfined_u:object_r:default_t:s0 /data/www/index.html

Dấu chấm được in sau drwxr-xr-x nghĩa là file đó có mang nhãn SELinux. default_t là nhãn mà một đường dẫn nhận được khi policy chưa từng biết đến nó, và không có quy tắc nào trong web server cho phép đọc loại nhãn đó. Log lỗi hiển thị một lỗi Unix thông thường, đó là lý do tại sao vấn đề này trông giống như một lỗi quyền truy cập:

2026/08/11 09:14:02 [error] 1183#1183: *1 open() "/data/www/index.html" failed (13: Permission denied), client: 203.0.113.9, server: _, request: "GET / HTTP/1.1"

Kernel trả về 13: Permission denied cho cả hai loại từ chối, loại thông thường và loại từ SELinux. Vì vậy, công việc đầu tiên là tìm ra lớp nào đã từ chối. Đừng bắt đầu với setenforce 0.

Phần mô hình bạn cần nắm

SELinux là cơ chế kiểm soát truy cập bắt buộc, thường được viết tắt là MAC. Mỗi tiến trình chạy trong một domain, ví dụ như httpd_t cho web server. Mỗi file và mỗi cổng mạng đều mang một type, ví dụ như httpd_sys_content_t. Policy là danh sách các kết hợp được cho phép giữa domain, type và hành động; bất kỳ thứ gì không nằm trong danh sách này đều bị từ chối. Nó chạy sau bước kiểm tra Unix truyền thống, vì vậy các bit quyền trong drwxr-xr-x vẫn phải cho phép truy cập trước. Cả hai lớp bảo mật đều phải đồng ý.

Một context đầy đủ có bốn trường phân tách bằng dấu hai chấm, ví dụ như system_u:system_r:httpd_t:s0: SELinux user, role, type và level. Trên server, bạn dành gần như toàn bộ thời gian làm việc với trường thứ ba, tức là type. Hai lệnh sau đây hiển thị các giá trị đang hoạt động:

ps -eZ | grep nginx
id -Z

Các worker của nginx hiển thị context kết thúc bằng httpd_t. Shell đăng nhập của bạn hiển thị unconfined_u:unconfined_r:unconfined_t:s0, vì policy targeted mặc định giới hạn các dịch vụ và không can thiệp vào người dùng tương tác. Điều này rất đáng lưu tâm, vì SELinux không thay thế việc chạy dịch vụ dưới quyền người dùng có đặc quyền tối thiểu. Nó giới hạn phạm vi mà một dịch vụ có thể truy cập sau khi bị kẻ tấn công xâm nhập.

Ba chế độ và các image có hỗ trợ SELinux

sestatus
getenforce

Enforcing chặn và ghi log. Permissive cho phép mọi thứ và ghi log những gì nó sẽ chặn. Disabled không tải bất kỳ policy nào. getenforce in ra chế độ hiện tại. sestatus cũng in ra chế độ từ /etc/selinux/config, đây là chế độ sẽ được áp dụng sau khi reboot.

Rocky Linux, AlmaLinux, Fedora và RHEL xuất xưởng với SELinux ở chế độ enforcing cùng policy targeted. Ubuntu và Debian sử dụng AppArmor thay thế, thực hiện cùng nhiệm vụ với cơ chế khác (phần cuối sẽ đề cập đến nội dung này). Do đó, cùng một ứng dụng có thể cài đặt thành công trên máy chủ này nhưng lại trả về lỗi 403 trên máy chủ khác.

Cài đặt các công cụ trước khi bạn cần đến chúng

sudo dnf install -y policycoreutils-python-utils setroubleshoot-server

semanage: command not found trên một bản image tối giản nghĩa là policycoreutils-python-utils đang bị thiếu: gói đó chứa semanageaudit2allow. setroubleshoot-server bổ sung sealert và ghi lại tóm tắt bằng tiếng Anh đơn giản cho mỗi lần từ chối truy cập vào journal. Hãy cài đặt cả hai trên một server mới, vì thời điểm bạn cần đến chúng cũng chính là lúc hệ thống đã gặp sự cố.

Cách đọc thông báo từ chối của SELinux trong audit log

Mỗi lần từ chối truy cập đều được audit daemon ghi lại dưới dạng thông báo AVC (access vector cache):

sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
type=AVC msg=audit(1754896442.881:412): avc:  denied  { read } for  pid=1183 comm="nginx" name="index.html" dev="vda1" ino=17203 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0

Bốn trường thông tin sẽ cho bạn biết toàn bộ sự việc. comm là chương trình bị chặn. scontext là ngữ cảnh nguồn, domain mà tiến trình đang chạy trong đó. tcontext là ngữ cảnh đích, nhãn dán trên đối tượng mà tiến trình cố gắng truy cập. tclass là loại đối tượng, trong trường hợp này là một file. Đọc tổng hợp lại: tiến trình trong httpd_t đã cố đọc một file có nhãn default_t, và permissive=0 xác nhận yêu cầu này thực sự bị chặn thay vì chỉ ghi log.

Nếu ausearch không hiển thị gì, có thể audit daemon chưa chạy. Khi đó các thông báo từ chối sẽ nằm trong kernel ring buffer:

sudo journalctl -k | grep -i avc

Bây giờ hãy chuyển bản ghi đó thành một câu dễ hiểu:

sudo ausearch -m AVC -ts recent | audit2why
sudo journalctl -t setroubleshoot --since today
sudo sealert -a /var/log/audit/audit.log

audit2why đọc các bản ghi tương tự và chỉ ra nguyên nhân mà nó nhận diện được: một boolean đang bị tắt, một nhãn không khớp với policy, hoặc hoàn toàn không có rule nào. sealert sẽ quét toàn bộ log và in ra lệnh đề xuất cho mỗi lần từ chối. Hãy coi đề xuất này là gợi ý. Cách diễn đạt có thể thay đổi giữa các bản release, và sealert đôi khi đề xuất một custom policy module trong khi cách giải quyết đúng chỉ là sửa nhãn bằng một dòng lệnh.

Một điều nữa cần lưu ý. Policy chứa các rule dontaudit giúp ẩn đi những lần từ chối được coi là vô hại, vì vậy một chương trình có thể hoạt động sai mà log vẫn trống. Hãy bỏ ẩn chúng trong thời gian thực hiện một bài kiểm tra:

sudo semodule -DB
# reproduce the problem, then read the log again
sudo semodule -B

Sửa đường dẫn bị gán nhãn sai bằng semanage fcontext và restorecon

Hai lệnh này cần được thực hiện theo đúng thứ tự. semanage fcontext -a ghi lại nhãn mặc định cho một đường dẫn. restorecon áp dụng nhãn mặc định đã ghi đó lên các file trên đĩa.

sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
ls -ldZ /data/www/index.html

Đường dẫn được hiểu là một biểu thức chính quy (regular expression). (/.*)? bao gồm chính thư mục đó và mọi thứ bên trong, đây là điều cần thiết cho một document root. Hãy kiểm tra những thay đổi trước khi thực hiện: sudo restorecon -Rvn /data/www in ra các thay đổi nhãn dự kiến, vì -n có nghĩa là không thực hiện hành động nào. Sau khi chạy restorecon thực tế, nhãn sẽ trở thành httpd_sys_content_t và lỗi 403 sẽ biến mất mà không cần khởi động lại service.

Chỉ dùng chcon để kiểm tra. chcon -t httpd_sys_content_t index.html thiết lập nhãn trực tiếp, nhưng lệnh restorecon tiếp theo, cập nhật gói hoặc relabel toàn bộ hệ thống sẽ ghi đè nó, vì chính sách vẫn quy định đường dẫn đó phải có nhãn khác. semanage fcontext là phiên bản nhãn tồn tại bền vững. Liệt kê các nhãn đã ghi lại bằng sudo semanage fcontext -l | grep '^/data'.

Nội dung mà service cần ghi vào phải có type khác. Sử dụng httpd_sys_rw_content_t cho thư mục upload hoặc cache, và chỉ giới hạn ở các đường dẫn đó: một trang web chỉ đọc nhưng lại nằm dưới một type có quyền ghi sẽ cấp quyền truy cập nhiều hơn mức ứng dụng cần.

Tại sao nhãn lại bị sai? Hầu như luôn luôn là do cách các file được di chuyển vào. mv giữ nguyên nhãn hiện có của file, vì vậy một trang web được di chuyển ra khỏi /root sẽ mang nhãn admin_home_t và giữ nguyên nhãn đó. Lệnh cp thông thường sẽ gán cho file mới nhãn mặc định của thư mục đích, đây thường là điều bạn muốn, trong khi cp -arsync -X sẽ sao chép nhãn từ nguồn cùng với file. Một lệnh git clone vào một thư mục cấp cao mới sẽ tạo ra default_t. Khi một trang web tải bình thường từ /usr/share/nginx/html nhưng lại lỗi khi nằm trong thư mục của bạn, đây chính là nguyên nhân.

Khắc phục một nhóm hành vi bằng boolean

Một số lỗi không phải do vấn đề nhãn (label). Một reverse proxy trên máy chủ Rocky hoặc AlmaLinux mới cài đặt trả về lỗi 502, và log lỗi ghi:

2026/08/11 10:02:55 [crit] 1183#1183: *3 connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream

Upstream của bạn vẫn ổn. Domain httpd_t mặc định không được phép mở kết nối mạng ra ngoài, vì vậy lệnh gọi connect() bị từ chối trước khi nó chạm tới loopback interface. Một switch điều khiển toàn bộ hành vi đó:

getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect on

-P là flag quan trọng: nó ghi giá trị vào đĩa. Nếu thiếu -P, thay đổi sẽ mất sau lần reboot tiếp theo, khiến dịch vụ chỉ hoạt động cho đến khi máy khởi động lại. Xác nhận bằng semanage boolean -l | grep httpd_can_network_connect, lệnh này sẽ in giá trị đang chạy bên cạnh giá trị đã lưu.

Hãy ưu tiên dùng boolean thay vì tự viết rule thủ công nếu có thể. Các boolean được phát hành kèm theo chính sách của bản phân phối, vì vậy chúng được duy trì, có tài liệu hướng dẫn và người sau dễ dàng tìm thấy. getsebool -a liệt kê tất cả các boolean có trên hệ thống.

Cấu hình service nghe trên một cổng không tiêu chuẩn

Các cổng cũng được gắn nhãn. Nếu bạn chuyển Nginx sang cổng 8081, nó sẽ từ chối khởi động:

nginx: [emerg] bind() to 0.0.0.0:8081 failed (13: Permission denied)

httpd_t chỉ có thể bind các cổng được gắn nhãn http_port_t, và 8081 không nằm trong số đó. Hãy thêm nó vào:

sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8081

Hãy kiểm tra danh sách trước. Một vài cổng cao đã được cho phép sẵn, bao gồm 8008 và 8443, và việc thêm một cổng đã tồn tại sẽ gây lỗi ValueError: Port tcp/8081 already defined. Nếu cổng đó đã thuộc về một loại khác, hãy thay đổi nó bằng semanage port -m -t http_port_t -p tcp 8081 thay vì thêm mới.

Lệnh tương tự cũng là cách để làm cho cổng SSH đã thay đổi hoạt động được. Bind to port 2222 on 0.0.0.0 failed: Permission denied trong journalctl -u sshd nghĩa là 2222 đang thiếu trong ssh_port_t, vì vậy hãy chạy sudo semanage port -a -t ssh_port_t -p tcp 2222 trước khi bạn khởi động lại daemon và đóng phiên làm việc. Đây là bước mà mọi người thường bỏ qua khi làm theo các hướng dẫn chung về tăng cường bảo mật SSH trên VPS trên các bản phân phối thuộc dòng Red Hat. SELinux không phải là firewall, vì vậy cổng vẫn phải được mở: sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload ở đây, hoặc ufw trên bản phân phối Debian hoặc Ubuntu.

Khi không có boolean và nhãn để thay đổi

Trường hợp này hiếm gặp trên một máy chủ thông thường, và đây là nơi người dùng dễ gây ra hư hỏng. audit2allow có thể tạo một module chính sách từ các từ chối (denial) trong log:

sudo ausearch -m AVC -ts recent -c nginx | audit2allow -M nginx_local
cat nginx_local.te
sudo semodule -i nginx_local.pp

Hãy đọc nginx_local.te trước khi bạn cài đặt nó. Hai thói quen sau sẽ giúp giữ an toàn. Hãy lọc đầu vào chỉ cho chương trình duy nhất mà bạn đang sửa bằng -c, vì việc pipe toàn bộ log từ chối của cả một tuần không liên quan vào audit2allow sẽ cấp quyền cho tất cả chúng cùng lúc. Và đừng bao giờ cài đặt một module được tạo từ một từ chối mà bạn không giải thích được: một quy tắc cho phép httpd_t đọc mọi file trên máy rất dễ tạo ra nhưng lại khó phát hiện sau vài tháng. Hãy xóa một module bằng sudo semodule -r nginx_local.

Permissive là chế độ chẩn đoán, không phải cách sửa lỗi

sudo setenforce 0
# reproduce the problem once, all the way through
sudo ausearch -m AVC -ts recent
sudo setenforce 1

Chế độ permissive cho phép truy cập và ghi lại log các hành động đó. Giá trị thực sự của nó nằm ở tính đầy đủ. Ở chế độ enforcing, service sẽ dừng lại ngay tại lần từ chối đầu tiên, vì vậy bạn phải sửa lỗi đó, khởi động lại, rồi mới gặp lỗi tiếp theo. Ở chế độ permissive, tiến trình vẫn tiếp tục chạy và log sẽ thu thập mọi hành động bị từ chối trong một lần duy nhất, sau đó bạn chuyển lại chế độ cũ và sửa tất cả cùng lúc.

setenforce không tác động đến /etc/selinux/config, nên việc reboot sẽ đưa hệ thống trở lại trạng thái enforcing. Đây là một cơ chế an toàn, và cũng là lý do tại sao một "cách sửa" chỉ bao gồm setenforce 0 lại xuất hiện trở lại vào thời điểm tồi tệ nhất. Nếu một service cần được nới lỏng quyền trong khi bạn đang xử lý, hãy đánh dấu domain đó thay vì toàn bộ máy chủ: sudo semanage permissive -a httpd_t giữ cho mọi thứ khác ở trạng thái enforcing, và sudo semanage permissive -d httpd_t sẽ hoàn tác thay đổi đó.

Tại sao việc vô hiệu hóa SELinux gây tốn kém hơn là sửa nhãn (label)

Thiết lập SELINUX=disabled trong /etc/selinux/config đánh đổi việc sửa một dòng nhãn lấy một máy chủ yếu đi vĩnh viễn. Sự khác biệt sẽ lộ rõ vào ngày một ứng dụng web bị tấn công. Ở chế độ enforcing, mã của kẻ tấn công chạy trong httpd_t, vì vậy nó có thể đọc nội dung web, trong khi việc đọc /etc/shadow hoặc ghi vào một unit của systemd sẽ bị chính sách từ chối bất kể người dùng Unix có được phép hay không. Nếu không có chính sách nào được nạp, mã đó sẽ có toàn quyền của tài khoản dịch vụ.

Việc vô hiệu hóa cũng để lại hậu quả mà bạn phải trả giá sau này. Khi không có chính sách nào được nạp, các file mới được tạo ra mà không có nhãn, khiến hệ thống file lệch khỏi chính sách. Việc bật lại SELinux sau đó đòi hỏi phải relabel toàn bộ, nếu không hàng loạt dịch vụ sẽ bị lỗi cùng lúc:

sudo fixfiles -F onboot
sudo reboot

Lệnh đó ghi /.autorelabel và relabel mọi hệ thống file trong lần khởi động tiếp theo. Trên ổ đĩa lớn, quá trình này mất nhiều thời gian và console trông như bị treo, vì vậy hãy thực hiện khi bạn có thể chờ đợi. Trên Rocky Linux và AlmaLinux 9, file cấu hình không còn tự tắt phần kernel, và cách chính thống để vô hiệu hóa hoàn toàn SELinux là dùng tham số kernel (sudo grubby --update-kernel ALL --args selinux=0). Biết lệnh đó sẽ giúp ích khi bạn tiếp quản máy chủ của người khác. Đó không phải là cách sửa lỗi 403.

Thêm một nhãn cho container

Trên các host thuộc dòng Red Hat, các tiến trình container chạy trong container_t và chỉ có thể đọc các file được gắn nhãn container_file_t. Một bind mount từ host sẽ thất bại với lỗi Permission denied bên trong container, trong khi ls -l trên host vẫn hiển thị hoàn toàn bình thường. Hậu tố :Z báo cho runtime biết cần phải gắn nhãn lại cho mount đó:

docker run -d -v /data/appdata:/var/lib/app:Z myimage

:Z gắn nhãn thư mục chỉ dành riêng cho container này. :z gắn nhãn thư mục để chia sẻ giữa các container. Nếu trỏ :Z vào một thư mục mà các dịch vụ khác đang sử dụng, nó sẽ gắn nhãn lại thư mục đó theo kiểu đệ quy, làm hỏng các dịch vụ đó, vì vậy hãy cấp cho mỗi container các đường dẫn riêng. Mọi thiết lập khác đều tương tự như bất kỳ image nào, đã được đề cập trong chạy Docker trên VPS.

Ubuntu và Debian cung cấp AppArmor

Cùng một công việc nhưng thiết kế khác nhau. AppArmor giới hạn một chương trình dựa trên đường dẫn đến file thực thi, sử dụng profile nằm trong /etc/apparmor.d/, thay vì gắn nhãn (label) cho các file trên đĩa. Không có gì cần gắn nhãn lại và không có restorecon. Bắt đầu tại đây:

sudo aa-status
sudo journalctl -k | grep -i apparmor

Một sự từ chối truy cập sẽ xuất hiện dưới dạng apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". Quy trình làm việc có cùng hình thức: đọc thông báo từ chối, tìm profile, thay đổi quy tắc. sudo apt install apparmor-utils cung cấp cho bạn aa-complain (chế độ permissive cho một profile) và aa-enforce để khôi phục lại trạng thái cũ. Ubuntu giới hạn một tập hợp các dịch vụ được đóng gói sẵn và để các dịch vụ còn lại không bị giới hạn, vì vậy hãy đọc aa-status để xem những gì thực sự đang hoạt động thay vì tự suy đoán.

Có một thói quen áp dụng cho cả hai hệ thống. Khi một dịch vụ báo lỗi Permission denied trên một thứ trông có vẻ chính xác, hãy đọc log bảo mật trước khi bạn đụng vào quyền truy cập. Các bit quyền hạn hiếm khi là nguyên nhân gây lỗi đến lần thứ hai.

FAQ

Tại sao nginx trả về lỗi 403 dù quyền truy cập file đã đúng?

Vì SELinux đã chặn quyền đọc, không phải do các bit quyền (permission bits). Web server chạy trong domain httpd_t và chỉ được phép đọc các file có nhãn dành cho nội dung web, nên file có nhãn default_t hoặc admin_home_t sẽ bị từ chối và nginx không có dữ liệu để phục vụ. Hãy xác nhận bằng sudo ausearch -m AVC -ts recent, lệnh này sẽ hiển thị scontext kết thúc bằng httpd_ttcontext đang giữ sai kiểu (type). Sau đó, hãy ghi lại nhãn đúng và áp dụng nó: sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" theo sau là sudo restorecon -Rv /data/www.

Chạy setenforce 0 để dịch vụ hoạt động có an toàn không?

setenforce 0 là một bước chẩn đoán, không phải là cách sửa lỗi. Hãy dùng nó để tái hiện vấn đề một lần nhằm thu thập mọi từ chối (denial) vào log, đọc chúng bằng sudo ausearch -m AVC -ts recent, sau đó chạy sudo setenforce 1 và khắc phục các nguyên nhân. Một server để ở chế độ permissive sẽ ghi log mọi từ chối nhưng không chặn bất kỳ cái nào, vì vậy bạn vừa giữ lại các thông báo rác vừa mất đi sự bảo vệ. Nếu một dịch vụ cần không gian hoạt động trong khi bạn xử lý, hãy chạy sudo semanage permissive -a httpd_t để các phần còn lại của máy chủ vẫn duy trì trạng thái enforcing.

Làm thế nào để chạy dịch vụ trên một cổng không tiêu chuẩn khi SELinux đang ở chế độ enforcing?

Hãy thêm cổng đó vào kiểu (type) mà dịch vụ được phép bind. Đối với web server trên cổng 8081: sudo semanage port -a -t http_port_t -p tcp 8081. Đối với SSH trên cổng 2222: sudo semanage port -a -t ssh_port_t -p tcp 2222. Hãy kiểm tra danh sách hiện tại trước bằng sudo semanage port -l | grep -w http_port_t, vì một cổng đã được liệt kê sẽ gây lỗi với ValueError: Port tcp/8081 already defined. Nếu thiếu bước này, daemon sẽ thoát khi khởi động với lỗi bind() ... Permission denied mặc dù không có tiến trình nào khác đang chiếm cổng đó.

Ubuntu có dùng SELinux không?

Không. Ubuntu và Debian sử dụng AppArmor, cơ chế này áp dụng profile dựa trên đường dẫn của file thực thi thay vì nhãn trên file. Hãy kiểm tra bằng sudo aa-status và tìm các dòng apparmor="DENIED" trong sudo journalctl -k. Ubuntu chỉ giới hạn một tập hợp các dịch vụ đóng gói sẵn, nên nhiều chương trình mặc định chạy ở trạng thái unconfined. Bạn sẽ gặp SELinux ở chế độ enforcing mặc định trên Rocky Linux, AlmaLinux, cùng với Fedora và RHEL.