SELinux : corriger l’erreur 403 de nginx
nginx renvoie 403 malgré des permissions correctes ? Lisez le refus SELinux, corrigez le label avec semanage et restorecon, puis gardez enforcing.
Pourquoi nginx renvoie 403 alors que les permissions du fichier sont correctes
Si nginx renvoie 403 alors que les bits de permission du fichier sont corrects, SELinux (Security-Enhanced Linux) refuse presque toujours la lecture. SELinux vérifie un second ensemble de règles après le contrôle des permissions classiques. Le serveur web ne peut lire que les fichiers associés à un label de contenu web. Votre fichier porte un autre label. L’appel open échoue donc et nginx n’a rien à envoyer.
Examinez le label, pas seulement le mode :
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.htmlLe point affiché après drwxr-xr-x indique que le fichier porte un label SELinux. default_t est le type attribué à un chemin lorsque la policy ne le connaît pas encore. Aucune règle du serveur web n’autorise la lecture de ce type. Le journal d’erreurs affiche une erreur Unix classique, ce qui fait penser à un problème de permissions :
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"Le noyau renvoie 13: Permission denied pour les deux types de refus : le refus classique et celui de SELinux. Commencez donc par déterminer quelle couche a refusé l’accès. Ne commencez pas par setenforce 0.
La partie du modèle dont vous avez besoin
SELinux est un contrôle d’accès obligatoire, généralement appelé MAC. Chaque processus s’exécute dans un domaine, comme httpd_t pour le serveur web. Chaque fichier et chaque port réseau possède un type, comme httpd_sys_content_t. La policy est une liste de combinaisons autorisées entre un domaine, un type et une action. Tout ce qui ne figure pas dans cette liste est refusé. Ce contrôle intervient après la vérification Unix classique. Les bits de permission de drwxr-xr-x doivent donc d’abord autoriser l’accès. Les deux couches doivent l’autoriser.
Un contexte complet comporte quatre champs séparés par des deux-points, comme system_u:system_r:httpd_t:s0 : l’utilisateur SELinux, le rôle, le type et le niveau. Sur un serveur, vous travaillez presque toujours sur le troisième champ, le type. Deux commandes affichent les valeurs actuelles :
ps -eZ | grep nginx
id -ZLes workers nginx affichent un contexte qui se termine par httpd_t. Votre shell de connexion affiche unconfined_u:unconfined_r:unconfined_t:s0, car la policy targeted par défaut confine les services et laisse les utilisateurs interactifs tranquilles. C’est important à savoir, car SELinux ne remplace pas l’exécution des services avec des utilisateurs disposant du moindre privilège. Il limite ce à quoi un service peut accéder après son breach.
Les trois modes et les images qui utilisent SELinux
sestatus
getenforceLe mode Enforcing bloque les actions et les journalise. Le mode Permissive autorise tout et journalise ce qu’il aurait bloqué. Le mode Disabled ne charge aucune policy. getenforce affiche le mode actuel. sestatus affiche également le mode défini dans /etc/selinux/config, qui est réappliqué après un redémarrage.
Rocky Linux, AlmaLinux, Fedora et RHEL utilisent SELinux en mode Enforcing avec la policy targeted. Ubuntu et Debian utilisent AppArmor, qui remplit le même rôle avec un mécanisme différent (la dernière section le présente). Une même application peut donc s’installer correctement sur l’un de vos serveurs et renvoyer une erreur 403 sur un autre.
Installez les outils avant d’en avoir besoin
sudo dnf install -y policycoreutils-python-utils setroubleshoot-serverSur une image minimale, semanage: command not found signifie que policycoreutils-python-utils est absent : ce paquet contient semanage et audit2allow. setroubleshoot-server ajoute sealert et écrit dans le journal un résumé en langage clair de chaque refus. Installez les deux sur un serveur fraîchement déployé, car lorsque vous en avez besoin, quelque chose est déjà en panne.
Comment lire un refus SELinux dans le journal d’audit
Chaque refus est enregistré par le démon d’audit sous la forme d’un message AVC (access vector cache) :
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=0Quatre champs suffisent à comprendre la situation. comm désigne le programme qui a été bloqué. scontext est le contexte source, c’est-à-dire le domaine dans lequel le processus s’exécutait. tcontext est le contexte cible, c’est-à-dire le label de l’objet auquel le processus a tenté d’accéder. tclass indique le type d’objet, ici un fichier. En les lisant ensemble : le processus dans httpd_t a tenté de lire un fichier portant le label default_t, et permissive=0 indique que la demande a bien été bloquée, et pas seulement enregistrée.
Si ausearch n’affiche rien, le démon d’audit n’est peut-être pas démarré. Les refus sont alors écrits dans le ring buffer du kernel :
sudo journalctl -k | grep -i avcTransformez maintenant l’enregistrement en une phrase française :
sudo ausearch -m AVC -ts recent | audit2why
sudo journalctl -t setroubleshoot --since today
sudo sealert -a /var/log/audit/audit.logaudit2why lit les mêmes enregistrements et indique la cause qu’il reconnaît : un boolean désactivé, un label qui ne correspond pas à la policy ou l’absence totale de règle. sealert parcourt tout le journal et affiche une commande suggérée pour chaque refus. Considérez cette suggestion comme une indication. Le texte varie selon les releases, et sealert propose parfois un custom policy module alors qu’une simple correction de label sur une ligne est la bonne solution.
Autre point important. La policy contient dontaudit règles qui masquent les refus considérés comme inoffensifs. Un programme peut donc avoir un comportement incorrect alors que le journal reste vide. Désactivez ce masquage pendant un test :
sudo semodule -DB
# reproduce the problem, then read the log again
sudo semodule -BCorriger un chemin mal étiqueté avec semanage fcontext et restorecon
Deux commandes sont nécessaires, et l’ordre compte. semanage fcontext -a enregistre l’étiquette qui devrait être associée à un chemin. restorecon applique cette valeur par défaut enregistrée aux fichiers présents sur le disque.
sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
ls -ldZ /data/www/index.htmlLe chemin est une expression régulière. (/.*)? couvre le répertoire lui-même et tout ce qu’il contient, ce qui convient à une racine de documents. Vérifiez les changements prévus avant de les appliquer : sudo restorecon -Rvn /data/www affiche les réétiquetages prévus, car -n signifie qu’aucune action ne sera effectuée. Après un véritable restorecon, l’étiquette affichée par httpd_sys_content_t est correcte et l’erreur 403 disparaît sans redémarrer le service.
Utilisez chcon uniquement pour un test. chcon -t httpd_sys_content_t index.html définit directement l’étiquette, puis le prochain restorecon, une mise à jour de paquet ou un réétiquetage complet la réinitialise, car la policy indique toujours que le chemin devrait avoir une autre étiquette. semanage fcontext est la version persistante. Affichez les règles enregistrées avec sudo semanage fcontext -l | grep '^/data'.
Les contenus que le service doit pouvoir modifier nécessitent un autre type. Utilisez httpd_sys_rw_content_t pour un répertoire d’upload ou de cache, et limitez ce type à ces chemins : un site en lecture seule placé sous un type accessible en écriture accorde plus de droits que l’application n’en a besoin.
Pourquoi l’étiquette était-elle incorrecte ? Presque toujours à cause de la manière dont les fichiers sont arrivés. mv conserve l’étiquette existante d’un fichier. Ainsi, un site déplacé depuis /root arrive avec l’étiquette admin_home_t et la conserve. Un cp simple attribue au nouveau fichier l’étiquette par défaut du répertoire de destination, ce qui est généralement le comportement recherché, tandis que cp -a et rsync -X copient les étiquettes de la source avec le fichier. Un git clone vers un nouveau répertoire de premier niveau produit default_t. Lorsqu’une page se charge correctement depuis /usr/share/nginx/html mais échoue depuis votre propre répertoire, c’est la raison.
Corriger un comportement avec un booléen
Certains échecs ne sont pas liés aux labels. Un reverse proxy installé sur un serveur Rocky ou AlmaLinux fraîchement configuré renvoie une erreur 502, et le journal d’erreurs indique :
2026/08/11 10:02:55 [crit] 1183#1183: *3 connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstreamVotre upstream fonctionne correctement. Le domaine httpd_t n’est pas autorisé par défaut à ouvrir des connexions réseau sortantes. L’appel connect() est donc refusé avant d’atteindre l’interface loopback. Un seul paramètre contrôle ce comportement :
getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect on-P est l’option importante : elle écrit la valeur sur disque. Sans -P, la modification est perdue au prochain redémarrage. Le service fonctionne alors jusqu’au redémarrage de la machine. Vérifiez avec semanage boolean -l | grep httpd_can_network_connect, qui affiche la valeur active à côté de la valeur enregistrée.
Préférez un booléen à une règle écrite manuellement lorsqu’un booléen existe. Les booléens font partie de la policy de la distribution. Ils sont donc maintenus, documentés et faciles à retrouver pour la personne qui interviendra ensuite. getsebool -a liste tous les booléens présents sur le système.
Autoriser un service à écouter sur un port non standard
Les ports sont également étiquetés. Déplacez nginx sur 8081 : il refuse alors de démarrer :
nginx: [emerg] bind() to 0.0.0.0:8081 failed (13: Permission denied)httpd_t peut lier des ports étiquetés http_port_t, et 8081 n’en fait pas partie. Ajoutez-le :
sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8081Vérifiez d’abord la liste. Plusieurs ports élevés sont déjà autorisés, notamment 8008 et 8443. Ajouter deux fois le même port échoue avec ValueError: Port tcp/8081 already defined. Si le port appartient déjà à un autre type, modifiez-le avec semanage port -m -t http_port_t -p tcp 8081 au lieu de l’ajouter.
La même commande permet de faire fonctionner un port SSH déplacé. Bind to port 2222 on 0.0.0.0 failed: Permission denied dans journalctl -u sshd signifie que 2222 manque dans ssh_port_t. Exécutez donc sudo semanage port -a -t ssh_port_t -p tcp 2222 avant de redémarrer le daemon et de fermer votre session. C’est l’étape souvent oubliée lorsqu’on suit un guide générique pour sécuriser SSH sur un VPS avec une image de la famille Red Hat. SELinux n’est pas non plus un firewall. Le port doit donc rester ouvert : sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload ici, ou ufw sur une image Debian ou Ubuntu.
Lorsqu’il n’y a ni booléen ni label à modifier
Ce cas est rare sur un serveur standard, et c’est dans cette situation que les erreurs peuvent causer des dommages. audit2allow peut créer un module de politique à partir des refus enregistrés dans le journal :
sudo ausearch -m AVC -ts recent -c nginx | audit2allow -M nginx_local
cat nginx_local.te
sudo semodule -i nginx_local.ppLisez nginx_local.te avant de l’installer. Deux habitudes permettent de limiter les risques. Filtrez l’entrée pour ne conserver que le programme que vous corrigez avec -c, car transmettre une semaine de refus sans rapport à audit2allow les autorise tous en une seule fois. N’installez jamais un module créé à partir d’un refus que vous ne pouvez pas expliquer : une règle qui permet à httpd_t de lire tous les fichiers du serveur est facile à générer et difficile à détecter plusieurs mois plus tard. Supprimez un module avec sudo semodule -r nginx_local.
Le mode permissif sert au diagnostic, pas à corriger le problème
sudo setenforce 0
# reproduce the problem once, all the way through
sudo ausearch -m AVC -ts recent
sudo setenforce 1Le mode permissif autorise l’accès et le journalise. Sa vraie valeur tient à son exhaustivité. En mode enforcing, le service s’arrête au premier refus. Vous corrigez ce refus, redémarrez, puis rencontrez le suivant. En mode permissif, l’exécution continue et le journal recueille tous les refus en une seule passe. Vous repassez ensuite en mode enforcing et les corrigez ensemble.
setenforce ne modifie pas /etc/selinux/config. Après un redémarrage, la machine repasse donc en mode enforcing. C’est un filet de sécurité. C’est aussi la raison pour laquelle un prétendu « correctif » consistant à utiliser setenforce 0 réapparaît au pire moment. Si un service a besoin de davantage de latitude pendant vos vérifications, marquez plutôt ce domaine que toute la machine : sudo semanage permissive -a httpd_t laisse tout le reste en mode enforcing, et sudo semanage permissive -d httpd_t annule cette modification.
Pourquoi désactiver SELinux coûte plus cher que corriger le label
Définir SELINUX=disabled dans /etc/selinux/config remplace une correction de label sur une ligne par un serveur durablement moins sécurisé. La différence apparaît le jour où une application web est compromise. En mode enforcing, le code de l’attaquant s’exécute dans httpd_t. Il peut donc lire le contenu web, mais la lecture de /etc/shadow ou l’écriture d’une unité systemd sont refusées par la policy, quels que soient les droits accordés par l’utilisateur Unix. Si aucune policy n’est chargée, ce même code obtient tous les droits du compte de service.
La désactivation a également un coût différé. Tant qu’aucune policy n’est chargée, les nouveaux fichiers sont créés sans label. Le système de fichiers s’écarte donc progressivement de la policy. La réactivation de SELinux nécessite ensuite un relabeling complet, faute de quoi de nombreux services échouent simultanément :
sudo fixfiles -F onboot
sudo rebootCette commande écrit /.autorelabel et relabelise tous les systèmes de fichiers lors du prochain boot. Sur un disque de grande capacité, l’opération prend longtemps et la console semble bloquée. Lancez-la lorsque vous pouvez attendre. Sous Rocky Linux et AlmaLinux 9, le fichier de configuration ne désactive plus à lui seul la partie kernel. La méthode documentée pour désactiver complètement SELinux consiste à utiliser un argument du kernel (sudo grubby --update-kernel ALL --args selinux=0). Cette commande est utile lorsque vous reprenez le serveur de quelqu’un d’autre. Elle ne corrige pas une erreur 403.
Les conteneurs ajoutent une étiquette supplémentaire
Sur un hôte de la famille Red Hat, les processus des conteneurs s’exécutent dans container_t et peuvent uniquement lire les fichiers portant l’étiquette container_file_t. Un bind mount depuis l’hôte échoue avec Permission denied dans le conteneur, alors que ls -l sur l’hôte semble parfaitement normal. Le suffixe :Z indique au runtime de réétiqueter le mount :
docker run -d -v /data/appdata:/var/lib/app:Z myimage:Z étiquette le répertoire pour ce conteneur uniquement. :z l’étiquette pour permettre son partage entre conteneurs. Si vous indiquez à :Z un répertoire utilisé par d’autres services, il réétiquette ce répertoire récursivement, ce qui perturbe ces services. Attribuez donc aux conteneurs leurs propres chemins. Pour le reste, la configuration est identique à celle de n’importe quelle autre image, comme expliqué dans exécuter Docker sur un VPS.
Ubuntu et Debian fournissent AppArmor
Même objectif, conception différente. AppArmor restreint un programme en fonction du chemin de son exécutable, à l’aide d’un profil placé sous /etc/apparmor.d/, au lieu d’étiqueter les fichiers sur le disque. Il n’y a rien à réétiqueter et aucun restorecon. Commencez ici :
sudo aa-status
sudo journalctl -k | grep -i apparmorUn refus apparaît sous la forme de apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". La méthode reste similaire : lisez le refus, trouvez le profil, puis modifiez la règle. sudo apt install apparmor-utils vous permet d’activer aa-complain (mode permissif pour un profil) et aa-enforce de rétablir le mode précédent. Ubuntu restreint un ensemble défini de services fournis par les paquets et laisse les autres sans confinement. Lisez donc aa-status pour vérifier ce qui est réellement actif, au lieu de le supposer.
Une bonne pratique s’applique aux deux systèmes. Lorsqu’un service signale Permission denied sur un élément qui semble correct, consultez le journal de sécurité avant de modifier les permissions. Les bits de permission sont rarement la cause deux fois de suite.
FAQ
Pourquoi nginx renvoie-t-il une erreur 403 lorsque les permissions du fichier sont correctes ?
Parce que SELinux a refusé la lecture, et non à cause des bits de permission. Le serveur web s’exécute dans le domaine httpd_t et peut uniquement lire les fichiers marqués pour le contenu web. Un fichier marqué default_t ou admin_home_t est donc refusé, et nginx n’a aucun contenu à servir. Vérifiez-le avec sudo ausearch -m AVC -ts recent : la commande indique scontext se terminant par httpd_t, ainsi que tcontext qui contient le mauvais type. Relevez ensuite le bon label et appliquez-le : sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?", puis sudo restorecon -Rv /data/www.
Est-il sûr d’exécuter setenforce 0 pour faire fonctionner un service ?
setenforce 0 est une étape de diagnostic, pas une correction. Utilisez-la pour reproduire le problème une fois afin que le journal collecte tous les refus en une seule passe. Lisez-les avec sudo ausearch -m AVC -ts recent, puis exécutez sudo setenforce 1 et corrigez les causes. Un serveur laissé en mode permissif journalise tous les refus, mais n’en bloque aucun. Vous conservez donc le bruit et perdez la protection. Si un service a besoin de cette latitude pendant vos interventions, exécutez sudo semanage permissive -a httpd_t afin que le reste de la machine reste en mode enforcing.
Comment exécuter un service sur un port non standard avec SELinux en mode enforcing ?
Ajoutez le port au type sur lequel le service est autorisé à écouter. Pour un serveur web sur 8081 : sudo semanage port -a -t http_port_t -p tcp 8081. Pour SSH sur 2222 : sudo semanage port -a -t ssh_port_t -p tcp 2222. Vérifiez d’abord la liste actuelle avec sudo semanage port -l | grep -w http_port_t, car l’ajout d’un port déjà présent échoue avec ValueError: Port tcp/8081 already defined. Sans cette étape, le daemon s’arrête au démarrage avec bind() ... Permission denied, même si aucun autre processus n’utilise le port.
Ubuntu utilise-t-il SELinux ?
Non. Ubuntu et Debian fournissent AppArmor, qui applique un profil associé au chemin d’un exécutable plutôt qu’aux labels des fichiers. Vérifiez son état avec sudo aa-status et recherchez les lignes apparmor="DENIED" dans sudo journalctl -k. Ubuntu confine un ensemble sélectionné de services fournis par les paquets. De nombreux programmes s’exécutent donc par défaut sans confinement. Vous rencontrerez SELinux en mode enforcing par défaut sur Rocky Linux et AlmaLinux, ainsi que sur Fedora et RHEL.