Chemins autorisés pour les liens symboliques

Par défaut, OxPHP refuse toute requête qui se résout vers un chemin situé hors du DOCUMENT_ROOT canonique. Un lien symbolique à l'intérieur du DOCUMENT_ROOT pointant vers un répertoire externe renvoie 404 et la résolution de chemin journalise Blocked request: resolved path escapes document root.

C'est le bon comportement par défaut : il empêche la traversée de répertoires et l'exposition accidentelle de fichiers de configuration ou de secrets situés un niveau au-dessus. Mais il bloque aussi des schémas légitimes que les frameworks utilisent depuis des années : le php artisan storage:link de Laravel, les bundles d'actifs de Symfony, les volumes de téléversement partagés montés dans plusieurs conteneurs.

SYMLINK_ALLOW_PATHS est l'activation explicite : vous y listez les chemins du système de fichiers vers lesquels les liens symboliques sous DOCUMENT_ROOT ont le droit de se résoudre. Tout ce qui ne figure pas dans la liste conserve le comportement strict du 404.

Configuration

bash
# Absolute paths, comma-separated SYMLINK_ALLOW_PATHS=/var/www/storage,/opt/shared/assets # Relative paths resolve against DOCUMENT_ROOT SYMLINK_ALLOW_PATHS=../storage,../shared/uploads # Mixed SYMLINK_ALLOW_PATHS=/opt/shared/cdn,../storage/app/public

Lorsque la variable n'est pas définie (le cas par défaut), aucun lien symbolique ne peut quitter le DOCUMENT_ROOT.

Exemple Laravel

bash
DOCUMENT_ROOT=/app/public SYMLINK_ALLOW_PATHS=../storage/app/public

php artisan storage:link crée alors public/storage -> ../storage/app/public à l'intérieur du projet. Les URL vers /storage/<file> se résolvent à travers le lien symbolique vers /app/storage/app/public/<file>, qui se canonicalise en un chemin autorisé par la liste d'autorisation. Aucune modification de code dans l'application.

Fonctionnement

Au démarrage, chaque entrée est résolue :

  • Entrées absolues — vérifiées par rapport à la liste noire (voir ci-dessous), puis passées à realpath(3) (c'est-à-dire std::fs::canonicalize). Si realpath échoue (la cible n'existe pas), le serveur refuse de démarrer.
  • Entrées relatives — jointes au DOCUMENT_ROOT canonique, puis passées à realpath.

Les chemins canoniques résultants sont stockés comme liste d'autorisation. Les doublons sont dédupliqués silencieusement.

Au moment de la requête, la couche de routage canonicalise le chemin de fichier résolu et vérifie qu'il satisfait l'une des conditions suivantes :

  1. il se trouve à l'intérieur du DOCUMENT_ROOT, ou
  2. il correspond exactement à l'une des entrées de la liste d'autorisation (cibles de type fichier), ou
  3. il commence par l'une des entrées de la liste d'autorisation suivie d'un / (cibles de type répertoire).

Le contrôle de routage met en cache son verdict par chemin ; le fichier finalement lu est donc contrôlé à nouveau.

Un `DOCUMENT_ROOT` derrière un lien symbolique de version

DOCUMENT_ROOT est résolu une seule fois, au démarrage, et les fichiers statiques lus depuis le disque ainsi que les scripts PHP sont contrôlés par rapport à cet emplacement à chaque ouverture. Une racine atteinte via un lien symbolique de version, comme /app/current/public dans une organisation à la Capistrano ou à la Deployer, répond donc 404 pour les deux dès que current bascule vers une nouvelle version, et ce jusqu'au redémarrage du serveur. Les fichiers servis depuis le cache en mémoire continuent de répondre avec les octets qu'ils contiennent. Faites pointer DOCUMENT_ROOT vers un chemin qui ne change pas d'une version déployée à l'autre, ou redémarrez le serveur lors de la bascule.

Fichiers statiques

Pour les fichiers statiques, le contrôle s'exécute une seconde fois au moment du service, sur le fichier effectivement ouvert et avant qu'aucun octet n'en soit lu. L'emplacement est tiré du descripteur ouvert (/proc/self/fd/N sous Linux, F_GETPATH sous macOS), si bien qu'un lien symbolique échangé après le contrôle de routage mis en cache est tout de même refusé. Là où le chemin du descripteur n'est pas disponible, le chemin est résolu à nouveau et doit désigner le même fichier que celui qui a été ouvert.

Note

Un fichier servi depuis le cache en mémoire n'est pas rouvert. Un lien échangé continue de renvoyer les octets mis en cache depuis son ancienne cible validée jusqu'à l'éviction de l'entrée ou, avec STATIC_REVALIDATE=on, jusqu'à ce que la revalidation remarque le changement.

Scripts PHP

Les scripts PHP bénéficient du même traitement dans tous les modes sauf le mode worker, dont le fichier d'entrée est un chemin configuré par l'administrateur. Le worker ouvre lui-même le script, contrôle de la même manière l'emplacement du descripteur ouvert, et transmet au moteur ce fichier ouvert plutôt que le chemin : le fichier ouvert est donc bien le fichier contrôlé. Il existe une exception : un script dont le nom résolu contient .phar et qui est une archive phar zip, tar ou compressée est ouvert une seconde fois, par son nom, par l'extension phar, et cette seconde ouverture n'est pas couverte par le contrôle.

Script au moment du service Résultat
N'est pas un fichier régulier (un répertoire, une FIFO), ou existe mais ne peut pas être lu 500, journalise Cannot open script. L'avertissement d'échec d'ouverture et l'erreur fatale propres à PHP ne sont pas produits.
Supprimé depuis le routage 404, y compris pour un script que détient OPcache, qui, avec opcache.validate_timestamps désactivé, s'exécuterait sinon depuis le cache sans que le fichier soit ouvert
Lien redirigé après le routage, vers un script ou vers un fichier texte que le moteur afficherait tel quel 404, journalise Blocked request: opened script escapes document root
Lien redirigé vers un autre script à l'intérieur du DOCUMENT_ROOT, ou vers une cible de la liste d'autorisation Exécute ce script

__FILE__, __DIR__ et $_SERVER['SCRIPT_FILENAME'] sont identiques à ce qu'ils seraient sans le contrôle. Comme le moteur reçoit l'emplacement résolu du fichier, il en découle trois conséquences :

  • OPcache conserve un script atteint via un lien sous le fichier vers lequel le lien se résout.
  • Le répertoire de travail du script est le répertoire de ce fichier. Pour un lien vers un fichier situé dans un autre répertoire, ce n'est pas le répertoire où se trouve le lien.
  • L'extension phar décide d'après ce nom résolu si le script est un phar. Un lien nommé app.phar.php qui pointe vers un phar au format zip dont le propre nom ne contient pas .phar est compilé comme un fichier ordinaire, et un lien dont le nom ne contient pas .phar mais qui pointe vers un phar dont le nom le contient s'exécute comme un phar.
Les fichiers inclus ne sont pas contrôlés

Le contrôle couvre le script vers lequel une requête a été routée. Les fichiers que ce script charge avec include ou require, ou qu'il ouvre par leur nom, ainsi que auto_prepend_file, sont résolus par PHP comme d'habitude. Là où des scripts ou leurs répertoires sont modifiables par du code auquel vous ne faites pas confiance, ajoutez aussi open_basedir.

open_basedir s'applique aussi au script routé lui-même : un script situé en dehors est refusé comme PHP refuse tout script qu'il ne peut pas ouvrir, avec le statut 500 et les avertissements habituels open_basedir restriction in effect. Il est vérifié au moment où PHP compile le script, au même point que pour un script que PHP ouvre lui-même : après l'exécution de auto_prepend_file, avec le répertoire du script comme répertoire de travail. Une entrée relative telle que open_basedir=. désigne donc le répertoire du script, et un open_basedir que le fichier préfixé restreint avec ini_set() s'applique au script qu'il préfixe — y compris un script qu'OPcache détient déjà, et que PHP aurait exécuté sans contrôle.

Liste noire

Un petit ensemble de chemins ne peut jamais figurer dans SYMLINK_ALLOW_PATHS ; des fautes de frappe et des malentendus élargiraient sinon considérablement la surface d'attaque. Le serveur refuse de démarrer si une entrée se résout vers un chemin de la liste noire.

Interdits en correspondance exacte :

text
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srv

Interdits en préfixe (l'entrée se trouve sous l'un de ces répertoires) :

text
/etc /proc /sys /dev /tmp /root /usr
Note

/var, /home et /srv sont en correspondance exacte uniquement : /srv seul est rejeté, mais /srv/myapp/storage est autorisé, tout comme /var/www/storage et /home/<any>/... le sont. Les entrées sont vérifiées deux fois : une fois par rapport au chemin brut fourni par l'administrateur (afin qu'un /etc -> /private/etc à la macOS ne puisse pas blanchir un chemin de la liste noire à travers realpath), une fois par rapport à la forme canonique (défense en profondeur contre les évasions de cible de lien symbolique).

La liste noire elle-même est codée en dur ; aucune variable d'environnement ne permet de l'étendre. Le comportement par défaut est le minimum conservateur qui attrape les erreurs de type faute de frappe ; les administrateurs qui ont besoin de politiques plus strictes doivent les superposer à l'extérieur (permissions du système de fichiers, restrictions de montage de conteneur, profils AppArmor/SELinux).

Modes de défaillance

Erreur de configuration Résultat
La cible de l'entrée n'existe pas sur le disque Le serveur refuse de démarrer, l'erreur nomme l'entrée et canonicalize
L'entrée correspond à la liste noire (brute ou canonique) Le serveur refuse de démarrer, l'erreur nomme l'entrée et la règle de la liste noire
Variable d'environnement vide ou composée uniquement d'espaces Traitée comme non définie — comportement strict par défaut
Entrées en double Dédupliquées silencieusement après canonicalisation
Aucun lien symbolique n'existe encore au démarrage La liste d'autorisation est enregistrée mais reste inerte jusqu'à l'apparition d'un lien symbolique ; aucun contrôle de démarrage n'exige le lien symbolique

Notes de sécurité

  • La liste d'autorisation est en opt-in — le comportement par défaut sûr « aucune évasion » est préservé lorsque la variable n'est pas définie
  • Les entrées sont canonicalisées au démarrage, de sorte que les .. et les liens symboliques intermédiaires dans le chemin de l'entrée sont réduits avant stockage
  • Les fichiers statiques et les scripts PHP sont contrôlés sur le fichier ouvert : sous Linux avec /proc monté, ainsi que sous macOS, échanger un lien symbolique après qu'il a été servi une fois ne peut donc pas faire provenir une réponse ultérieure, ni exécuter un script, d'un endroit situé hors des emplacements autorisés. La solution de repli utilisée ailleurs est plus restreinte, comme décrit ci-dessus
  • Les cibles de type fichier correspondent exactement ; les cibles de type répertoire correspondent par préfixe de répertoire. Lister un fichier à /etc/passwd serait de toute façon rejeté par la liste noire, mais plus généralement : lister un fichier unique à /opt/shared/license.key n'accorde pas implicitement l'accès à ses voisins
  • La validation au moment du routage est mise en cache par URL demandée (un realpath par URL unique jusqu'à éviction). Le contrôle au moment du service ajoute une recherche de l'emplacement du descripteur ouvert pour chaque fichier statique lu depuis le disque, et rien pour un fichier servi depuis le cache en mémoire. Pour un script PHP hors mode worker, il ajoute environ huit appels système à chaque requête (comptés avec strace sur PHP 8.4) : un open et un close du script, cette recherche, et des appels fstat, fcntl et lseek sur le descripteur. Cela vaut aussi pour une requête à laquelle OPcache répond, qui sinon ne toucherait pas au fichier lorsque opcache.validate_timestamps est désactivé. Sur un script qui affiche un seul mot, ce surcoût a représenté environ 3 % du débit mesuré ; une application réelle y consacre une part proportionnellement plus faible de sa requête

Prompts pour votre agent

Ces prompts supposent un agent connecté au serveur MCP de la documentation OxPHP — il lira lui-même les pages dont il a besoin.

prompt
Mets en place `SYMLINK_ALLOW_PATHS` pour les liens de stockage dont dépend mon application. Lis la page `security/symlink-allow-paths` via le serveur MCP de la documentation OxPHP. Trouve les liens symboliques sous mon `DOCUMENT_ROOT`, par exemple un lien du genre `storage:link`, et leurs cibles réelles, puis donne-moi les entrées de la liste d'autorisation et signale toute cible qui tomberait sur la liste noire.

Voir aussi

Une erreur ? Signalez-la →