لماذا يعرض nginx خطأ 403 رغم صحة أذونات الملف؟
يظهر الخطأ 403 رغم صحة الأذونات بسبب SELinux. تعلّم قراءة الرفض، وإصلاح التسمية باستخدام semanage وrestorecon، مع إبقاء الوضع enforcing.
لماذا يعرض nginx الخطأ 403 لملف أذوناته صحيحة
يعرض nginx الخطأ 403 لملف تكون بتات أذوناته صحيحة يكون سببه في الغالب SELinux (Linux المعزّز بالأمان) الذي يرفض القراءة. يتحقق SELinux من مجموعة ثانية من القواعد بعد اجتياز فحص الأذونات العادية، ولا يُسمح لخادم الويب إلا بقراءة الملفات التي تحمل تسمية محتوى ويب. يحمل ملفك تسمية مختلفة، لذلك تفشل عملية الفتح ولا يجد nginx شيئاً يرسله.
افحص التسمية، وليس النمط فقط:
ls -ldZ /data/www /data/www/index.htmldrwxr-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تعني النقطة المطبوعة بعد drwxr-xr-x أن الملف يحمل تسمية SELinux. أما default_t فهي التسمية التي يحصل عليها المسار عندما لا تكون السياسة قد تعرّفت إليه من قبل، ولا يسمح أي شيء في قواعد خادم الويب بقراءة هذا النوع. يعرض سجل الأخطاء خطأ Unix عادياً، ولذلك يبدو الأمر كأنه مشكلة في الأذونات:
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"تعيد النواة 13: Permission denied لنوعي الرفض، الرفض العادي ورفض SELinux. لذلك تتمثل الخطوة الأولى في تحديد الطبقة التي رفضت الطلب. لا تبدأ باستخدام setenforce 0.
الجزء الذي تحتاج إليه من النموذج
SELinux هو نظام للتحكم الإلزامي في الوصول، ويُكتب عادةً MAC. تعمل كل عملية ضمن نطاق، مثل httpd_t لخادم الويب. ويحمل كل ملف وكل منفذ شبكة نوعاً، مثل httpd_sys_content_t. تتكون السياسة من قائمة بالتركيبات المسموح بها من النطاق والنوع والإجراء، ويُرفض أي شيء غير موجود في هذه القائمة. تُطبَّق السياسة بعد فحص Unix التقليدي، لذلك يجب أن تسمح بتات الصلاحيات في drwxr-xr-x بالوصول أولاً. يجب أن توافق الطبقتان معاً.
يحتوي السياق الكامل على 4 حقول تفصل بينها نقطتان، مثل system_u:system_r:httpd_t:s0: مستخدم SELinux، والدور، والنوع، والمستوى. في الخادم، ستتعامل طوال الوقت تقريباً مع الحقل الثالث، أي النوع. يعرض أمران القيم الحالية:
ps -eZ | grep nginx
id -Zتُظهر عمليات nginx العاملة سياقاً ينتهي بـ httpd_t. وتُظهر صدفة تسجيل الدخول لديك unconfined_u:unconfined_r:unconfined_t:s0، لأن سياسة targeted الافتراضية تقيّد الخدمات وتترك المستخدمين التفاعليين دون تقييد. من المهم معرفة ذلك، لأن SELinux لا يحل محل تشغيل الخدمات باستخدام حسابات تملك أقل قدر من الامتيازات. بل يحدّد الموارد التي يمكن للخدمة الوصول إليها بعد اختراقها.
الأنماط الثلاثة، والصور التي تتضمن SELinux
sestatus
getenforceيمنع وضع Enforcing العمليات ويسجّلها. يسمح وضع Permissive بكل شيء ويسجّل ما كان سيمنعه. لا يحمّل وضع Disabled أي سياسة. يعرض getenforce الوضع الحالي. ويعرض sestatus أيضاً الوضع من /etc/selinux/config، وهو الوضع الذي يعود بعد إعادة التشغيل.
تأتي Rocky Linux وAlmaLinux وFedora وRHEL مع SELinux في وضع Enforcing وبسياسة targeted. أما Ubuntu وDebian فتأتيان مع AppArmor بدلاً منه، إذ يؤدي المهمة نفسها بآلية مختلفة (يتناول القسم الأخير ذلك). لذلك قد يُثبَّت التطبيق نفسه بنجاح على أحد خوادمك، ثم يعرض الخطأ 403 على خادم آخر.
ثبّت الأدوات قبل أن تحتاج إليها
sudo dnf install -y policycoreutils-python-utils setroubleshoot-serversemanage: command not found على صورة مصغّرة يعني أن policycoreutils-python-utils غير موجودة: تحتوي تلك الحزمة على semanage وaudit2allow. تضيف setroubleshoot-server ميزة sealert، وتكتب ملخصاً واضحاً لكل حالة رفض في السجل. ثبّت الأداتين على خادم جديد، لأنك ستحتاج إليهما عادةً عندما يكون شيء ما معطلاً بالفعل.
كيفية قراءة رفض SELinux في سجل التدقيق
يسجّل audit daemon كل حالة رفض في رسالة AVC (ذاكرة التخزين المؤقت لمتجه الوصول):
sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recenttype=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تحمل أربعة حقول القصة كاملة. comm هو البرنامج الذي جرى حظره. scontext هو السياق المصدر، أي النطاق الذي كانت العملية تعمل ضمنه. tcontext هو السياق الهدف، أي التسمية الموجودة على العنصر الذي حاولت العملية الوصول إليه. tclass هو نوع العنصر، وهو هنا ملف. عند قراءة هذه الحقول معاً، يتضح أن العملية في httpd_t حاولت قراءة ملف يحمل التسمية default_t، وأن permissive=0 يوضح أن الطلب حُظر فعلاً، وليس أنه سُجّل فقط.
إذا لم يطبع ausearch أي شيء، فقد لا يكون audit daemon قيد التشغيل. في هذه الحالة، تُكتب حالات الرفض في المخزن الحلقي للنواة بدلاً من ذلك:
sudo journalctl -k | grep -i avcحوّل السجل الآن إلى جملة:
sudo ausearch -m AVC -ts recent | audit2why
sudo journalctl -t setroubleshoot --since today
sudo sealert -a /var/log/audit/audit.logيقرأ audit2why السجلات نفسها، ويذكر السبب الذي تعرّف عليه: قيمة منطقية متوقفة، أو تسمية لا تطابق السياسة، أو عدم وجود قاعدة على الإطلاق. يفحص sealert السجل بأكمله، ويطبع أمراً مقترحاً لكل حالة رفض. تعامل مع الاقتراح على أنه إرشاد فقط. تختلف الصياغة بين الإصدارات، وقد يقترح sealert أحياناً وحدة سياسة مخصصة، بينما يكون إصلاح التسمية في سطر واحد هو الحل الصحيح.
هناك أمر آخر يجب معرفته. تحتوي السياسة على قواعد dontaudit تخفي حالات الرفض التي تُعد غير ضارة، لذلك قد يتصرف البرنامج بشكل غير صحيح بينما يبقى السجل فارغاً. أظهر هذه الحالات طوال مدة اختبار واحد:
sudo semodule -DB
# reproduce the problem, then read the log again
sudo semodule -Bإصلاح وسم مسار غير صحيح باستخدام semanage fcontext وrestorecon
استخدم أمرين، وترتيبهما مهم. يسجّل semanage fcontext -a الوسم الذي ينبغي أن يحمله المسار. ويطبّق restorecon هذا الإعداد الافتراضي المسجّل على الملفات الموجودة على القرص.
sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
ls -ldZ /data/www/index.htmlالمسار تعبير نمطي. يشمل (/.*)? الدليل نفسه وكل ما تحته، وهذا ما يحتاج إليه جذر المستندات. اعرض التغييرات المتوقعة قبل تطبيقها: يطبع sudo restorecon -Rvn /data/www عمليات إعادة الوسم المخططة، لأن -n يعني عدم تنفيذ أي إجراء. بعد تنفيذ restorecon فعلي، يظهر الوسم عبر httpd_sys_content_t ويختفي الخطأ 403 من دون إعادة تشغيل الخدمة.
استخدم chcon للاختبار فقط. يضبط chcon -t httpd_sys_content_t index.html الوسم مباشرة، وتعيد restorecon التالية أو تحديث الحزمة أو إعادة الوسم الكاملة ضبطه، لأن السياسة لا تزال تحدد أن المسار ينبغي أن يحمل وسماً آخر. أما semanage fcontext فهو الإصدار الذي يستمر. اعرض ما سجّلته باستخدام sudo semanage fcontext -l | grep '^/data'.
يحتاج المحتوى الذي يجب أن تكتب فيه الخدمة إلى نوع مختلف. استخدم httpd_sys_rw_content_t لدليل الرفع أو ذاكرة التخزين المؤقت، واقصره على هذه المسارات: فوضع موقع للقراءة فقط تحت نوع قابل للكتابة يمنح صلاحيات وصول أكبر مما يحتاج إليه التطبيق.
لماذا كان الوسم غير صحيح أصلاً؟ السبب في الغالب هو طريقة وصول الملفات. يحافظ mv على الوسم الحالي للملف، لذلك يصل موقع نُقل من /root بوسم admin_home_t ويبقى كذلك. يمنح cp العادي الملف الجديد الوسم الافتراضي لدليل الوجهة، وهذا ما تريده عادةً، بينما ينسخ cp -a وrsync -X وسوم المصدر مع الملف. يؤدي إجراء git clone إلى دليل جديد من المستوى الأعلى إلى إنشاء default_t. عندما تُحمَّل الصفحة بشكل صحيح من /usr/share/nginx/html وتفشل عند تحميلها من دليلك الخاص، فهذا هو السبب.
إصلاح فئة من السلوك باستخدام قيمة منطقية
لا تكون بعض حالات الفشل ناتجة عن مشكلة في التسمية. يعيد reverse proxy على خادم Rocky أو AlmaLinux حديث التثبيت الرمز 502، ويعرض سجل الأخطاء ما يلي:
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 لديك سليم. لا يسمح نطاق httpd_t بفتح اتصالات شبكة صادرة افتراضياً، لذلك يُرفض استدعاء connect() قبل أن يصل إلى واجهة loopback. يتحكم مفتاح واحد في هذا السلوك بالكامل:
getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect onالعلامة -P هي المهمة: فهي تكتب القيمة على القرص. من دون -P يضيع التغيير عند إعادة التشغيل التالية، فتعمل الخدمة حتى يُعاد تشغيل الجهاز. أكّد ذلك باستخدام semanage boolean -l | grep httpd_can_network_connect، الذي يطبع القيمة قيد التشغيل بجانب القيمة المخزنة.
استخدم قيمة منطقية بدلاً من قاعدة مكتوبة يدوياً كلما توفرت قيمة منطقية مناسبة. تأتي القيم المنطقية مع سياسة التوزيعة، لذلك تتم صيانتها وتوثيقها، ويسهل على الشخص التالي العثور عليها. يعرض getsebool -a جميع القيم الموجودة على النظام.
السماح لخدمة بالاستماع على منفذ غير قياسي
تُصنَّف المنافذ أيضاً. انقل nginx إلى المنفذ 8081، وسترفض الخدمة التشغيل:
nginx: [emerg] bind() to 0.0.0.0:8081 failed (13: Permission denied)يمكن لـhttpd_t ربط المنافذ المصنّفة http_port_t، والمنفذ 8081 ليس من بينها. أضِفه:
sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8081تحقق من القائمة أولاً. توجد عدة منافذ عالية مسموح بها مسبقاً، منها 8008 و8443، كما أن إضافة المنفذ مرتين تؤدي إلى فشل الأمر مع ValueError: Port tcp/8081 already defined. إذا كان المنفذ ينتمي مسبقاً إلى نوع مختلف، فغيّر نوعه باستخدام semanage port -m -t http_port_t -p tcp 8081 بدلاً من إضافته.
الأمر نفسه مطلوب لتشغيل منفذ SSH بعد تغييره. يعني Bind to port 2222 on 0.0.0.0 failed: Permission denied في journalctl -u sshd أن المنفذ 2222 مفقود من ssh_port_t، لذا شغّل sudo semanage port -a -t ssh_port_t -p tcp 2222 قبل إعادة تشغيل daemon وإنهاء جلستك. يتجاوز الناس هذه الخطوة عند اتباع دليل عام لـتعزيز أمان SSH على VPS في صورة من عائلة Red Hat. لا يُعد SELinux جداراً نارياً أيضاً، لذلك يجب فتح المنفذ: باستخدام sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload هنا، أو ufw في صورة Debian أو Ubuntu.
عندما لا توجد قيمة منطقية ولا تسمية لتغييرها
هذا نادر في الخادم العادي، وهنا تحدث الأضرار غالباً. يستطيع audit2allow إنشاء وحدة سياسة من حالات الرفض المسجلة في السجل:
sudo ausearch -m AVC -ts recent -c nginx | audit2allow -M nginx_local
cat nginx_local.te
sudo semodule -i nginx_local.ppاقرأ nginx_local.te قبل تثبيتها. تساعد عادتان على إبقاء العملية آمنة. صفِّ الإدخال ليقتصر على البرنامج الذي تعمل على إصلاحه باستخدام -c، لأن تمرير حالات رفض غير مرتبطة من أسبوع كامل إلى audit2allow يمنحها كلها دفعة واحدة. ولا تثبّت أبداً وحدة مبنية على حالة رفض لا تستطيع تفسيرها؛ فمن السهل إنشاء قاعدة تسمح لـhttpd_t بقراءة كل ملف على الخادم، لكن من الصعب اكتشافها بعد أشهر. أزل الوحدة باستخدام sudo semodule -r nginx_local.
الوضع Permissive مخصّص للتشخيص وليس للإصلاح
sudo setenforce 0
# reproduce the problem once, all the way through
sudo ausearch -m AVC -ts recent
sudo setenforce 1يسمح الوضع Permissive بالوصول ويسجّله في السجلات. وتكمن قيمته الفعلية في الشمولية. في وضع enforcing، تتوقف الخدمة عند أول رفض، فتصلح هذا الرفض ثم تعيد التشغيل لتواجه الرفض الثاني. أما في وضع Permissive، فتستمر العملية ويجمع السجل كل حالات الرفض في مرور واحد. بعد ذلك تعود إلى وضع enforcing وتصلحها معاً.
لا يغيّر setenforce قيمة /etc/selinux/config، لذلك تعيد عملية reboot الخادم إلى وضع enforcing. وهذا يمثل شبكة أمان، وهو يوضح أيضاً سبب ظهور "الإصلاح" الذي اقتصر على setenforce 0 في أسوأ وقت ممكن. إذا احتاجت خدمة واحدة إلى مساحة أثناء العمل عليها، فحدّد ذلك النطاق بدلاً من تغيير وضع الجهاز بالكامل: يترك sudo semanage permissive -a httpd_t كل ما عداه في وضع enforcing، ويعكس sudo semanage permissive -d httpd_t هذا التغيير.
لماذا يكلّف تعطيل SELinux أكثر من إصلاح التسمية
إن ضبط SELINUX=disabled في /etc/selinux/config يستبدل إصلاح تسمية في سطر واحد بخادم أضعف بشكل دائم. يظهر الفرق في اليوم الذي يتم فيه اختراق تطبيق ويب. عند تفعيل وضع enforcing، تعمل شيفرة المهاجم ضمن httpd_t، ولذلك قد تقرأ محتوى الويب، بينما ترفض السياسة قراءة /etc/shadow أو كتابة وحدة systemd، مهما كانت الصلاحيات التي كان مستخدم Unix سيسمح بها. عند عدم تحميل أي سياسة، تحصل الشيفرة نفسها على كل ما يملكه حساب الخدمة من صلاحيات.
ينطوي التعطيل أيضاً على تكلفة تدفعها لاحقاً. عند عدم تحميل أي سياسة، تُنشأ الملفات الجديدة من دون تسمية، ولذلك ينحرف نظام الملفات عن السياسة. وعند إعادة تفعيل SELinux، ستحتاج إلى إعادة تسمية كاملة، وإلا فقد تتعطل مجموعة كبيرة من الخدمات دفعة واحدة:
sudo fixfiles -F onboot
sudo rebootيكتب ذلك /.autorelabel ويعيد تسمية كل نظام ملفات أثناء الإقلاع التالي. يستغرق الأمر وقتاً طويلاً على قرص كبير، وقد تبدو وحدة التحكم متوقفة، لذلك شغّله عندما يتوفر لديك وقت للانتظار. في Rocky Linux وAlmaLinux 9، لم يعد ملف الإعداد يعطّل جزء kernel تلقائياً، والطريقة الموثقة لتعطيل SELinux بالكامل هي استخدام وسيطة kernel (sudo grubby --update-kernel ALL --args selinux=0). يفيدك هذا الأمر عندما تتولى إدارة خادم أعدّه شخص آخر. لكنه ليس الحل لخطأ 403.
تضيف الحاويات تسمية أخرى
على مضيف من عائلة Red Hat، تعمل عمليات الحاويات ضمن container_t، ولا يمكنها قراءة الملفات التي تحمل إلا التسمية container_file_t. يفشل الربط من المضيف داخل الحاوية مع Permission denied، رغم أن ls -l على المضيف يبدو طبيعياً تماماً. تخبر اللاحقة :Z بيئة التشغيل بإعادة تسمية نقطة الربط:
docker run -d -v /data/appdata:/var/lib/app:Z myimageتضع :Z تسمية على الدليل لهذه الحاوية وحدها. وتضع :z تسمية عليه للسماح بمشاركته بين الحاويات. إذا أشرت باستخدام :Z إلى دليل تستخدمه خدمات أخرى، فستُعاد تسمية ذلك الدليل بشكل متكرر، ما يعطّل تلك الخدمات؛ لذلك خصّص للحاويات مساراتها الخاصة. ويتطابق كل ما عدا ذلك في الإعداد مع أي صورة أخرى، كما هو موضّح في تشغيل Docker على VPS.
توفّر Ubuntu وDebian نظام AppArmor
المهمة نفسها، لكن التصميم مختلف. يقيّد AppArmor البرنامجَ استناداً إلى مسار الملف التنفيذي، باستخدام ملف تعريف ضمن /etc/apparmor.d/، بدلاً من وسم الملفات على القرص. لا توجد ملفات تحتاج إلى إعادة وسم، ولا يوجد restorecon. ابدأ هنا:
sudo aa-status
sudo journalctl -k | grep -i apparmorيظهر الرفض بصيغة apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". وتبقى خطوات المعالجة مشابهة: اقرأ الرفض، واعثر على ملف التعريف، ثم عدّل القاعدة. يوفّر sudo apt install apparmor-utils الأمر aa-complain (وضع السماح لملف تعريف واحد) وaa-enforce لإعادته إلى وضعه السابق. تقيّد Ubuntu مجموعة محددة من الخدمات المثبّتة من الحزم، وتترك البقية غير مقيّدة. لذلك اقرأ aa-status لمعرفة ما هو نشط فعلياً بدلاً من الافتراض.
تسري عادة واحدة على النظامين. عندما تبلغ خدمة عن Permission denied بشأن شيء يبدو صحيحاً، اقرأ سجل الأمان قبل تعديل الأذونات. نادراً ما تكون بتات الأذونات هي المشكلة في المرتين.
FAQ
لماذا يعرض nginx الخطأ 403 عندما تكون أذونات الملف صحيحة؟
لأن SELinux منع القراءة، وليس بسبب بتات الأذونات. يعمل خادم الويب ضمن النطاق httpd_t، ولا يمكنه قراءة إلا الملفات الموسومة بمحتوى الويب. لذلك يُرفض الملف الموسوم بـdefault_t أو admin_home_t، ولا يجد nginx ما يقدمه. أكّد ذلك باستخدام sudo ausearch -m AVC -ts recent، الذي يعرض scontext منتهياً بـhttpd_t، ويبيّن أن tcontext يحمل النوع الخاطئ. بعد ذلك سجّل الوسم الصحيح وطبّقه: sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" ثم sudo restorecon -Rv /data/www.
هل من الآمن تشغيل setenforce 0 لتشغيل خدمة؟
تُعد setenforce 0 خطوة تشخيصية، وليست إصلاحاً. استخدمها لإعادة إنتاج المشكلة مرة واحدة حتى يسجّل السجل كل حالات الرفض في مرور واحد، واقرأها باستخدام sudo ausearch -m AVC -ts recent، ثم شغّل sudo setenforce 1 وأصلح الأسباب. عندما يكون الخادم في الوضع permissive، يسجّل كل حالات الرفض ولا يمنع أياً منها. وبذلك تحتفظ بالضوضاء وتفقد الحماية. إذا احتاجت خدمة واحدة إلى مساحة أثناء العمل، فشغّل sudo semanage permissive -a httpd_t حتى يبقى باقي الجهاز في وضع enforcing.
كيف أشغّل خدمة على منفذ غير قياسي مع تفعيل SELinux enforcing؟
أضف المنفذ إلى النوع الذي يُسمح للخدمة بالارتباط به. لتشغيل خادم ويب على 8081: sudo semanage port -a -t http_port_t -p tcp 8081. ولتشغيل SSH على 2222: sudo semanage port -a -t ssh_port_t -p tcp 2222. تحقّق أولاً من القائمة الحالية باستخدام sudo semanage port -l | grep -w http_port_t، لأن إضافة منفذ موجود فيها تؤدي إلى ValueError: Port tcp/8081 already defined. من دون هذه الخطوة، تنتهي العملية الخفية عند بدء التشغيل مع bind() ... Permission denied، حتى إذا لم تكن هناك عملية أخرى تشغل المنفذ.
هل يحتوي Ubuntu على SELinux؟
لا. يأتي Ubuntu وDebian مع AppArmor، الذي يفرض ملفاً تعريفياً مرتبطاً بمسار الملف التنفيذي، بدلاً من الاعتماد على وسوم الملفات. تحقّق منه باستخدام sudo aa-status، وابحث عن الأسطر apparmor="DENIED" في sudo journalctl -k. يفرض Ubuntu قيوداً على مجموعة محددة من الخدمات المضمّنة في الحزم، لذلك تعمل برامج كثيرة دون قيود افتراضياً. في Rocky Linux وAlmaLinux ستجد SELinux في وضع enforcing افتراضياً، وكذلك في Fedora وRHEL.