Règles de refus de chemins (.oxphpdeny)

Un fichier .oxphpdeny placé au sommet de DOCUMENT_ROOT liste les chemins de requête que le serveur ne doit pas servir directement. Il s'écrit dans la syntaxe de .gitignore, avec un ajout : une règle préfixée par > confie la requête au script d'entrée de l'application au lieu de la refuser.

.oxphpdeny
# Never served — answered with PHP_DENY_FALLBACK (404 by default) /vendor /composer.* *.sql *.bak # Not served directly — the front controller decides > /storage/invoices/

Il est destiné aux fichiers qu'une racine web ne devrait pas contenir mais contient souvent : vendor/ et composer.json dans une application héritée servie depuis la racine de son projet, les dumps de base de données et les sauvegardes, les artefacts de build et les source maps dans le public/ d'un framework. Les règles s'appliquent dans les quatre modes de routage, aux fichiers statiques comme aux scripts PHP. Elles sont vérifiées avant tout accès disque, si bien qu'un chemin refusé reçoit la même réponse, que le fichier existe ou non.

Note

Le fichier est lu une seule fois, au démarrage : les modifications prennent effet au démarrage suivant. Sans le fichier, rien ne change. Le fichier lui-même n'est jamais servi. Comme tout segment de chemin commençant par . autre qu'un /.well-known/ initial, il est refusé avant le routage par le blocage des chemins commençant par un point.

Syntaxe

Tout ce que .gitignore prend en charge, .oxphpdeny le prend en charge :

  • Les lignes vides et celles qui commencent par # sont ignorées. Une marque d'ordre des octets (BOM) UTF-8 et les fins de ligne CRLF sont acceptées.
  • Les espaces finales sont supprimées, sauf si elles sont échappées (\ ).
  • ! réinclut ce qu'une règle précédente a exclu. \# et \! en début de ligne sont un # et un ! littéraux.
  • Un / final restreint la règle aux répertoires.
  • Un / au début ou au milieu ancre le motif au sommet de DOCUMENT_ROOT. Sans lui, le motif correspond à n'importe quelle profondeur : vendor couvre /vendor et /lib/vendor.
  • * et ? restent dans un seul segment de chemin ; [abc], [a-z], [!0-9] et [^0-9] sont des classes de caractères. Comme dans git, ? et une classe correspondent à un seul octet, et une classe ne correspond jamais à / : [!-] désigne n'importe quel octet d'un nom sauf -. Une lettre hors ASCII occupe deux octets ou plus, si bien que seul * peut la représenter : écrivez /caf*.txt, pas /caf?.txt. Les classes POSIX, les échappements par barre oblique inverse, les caractères hors ASCII, un - juste après une plage et une plage à l'envers ne sont pas pris en charge dans une classe, et une classe négative dans une règle de refus ou une règle > ne peut pas nommer une lettre ASCII (voir Différences avec .gitignore).
  • **/ au début, /** à la fin et /**/ au milieu traversent les répertoires ; ** partout ailleurs est un simple *. Une suite plus longue de * compte comme **, comme dans git : ***/secret.txt couvre /secret.txt à n'importe quelle profondeur.
  • La dernière règle qui correspond l'emporte, et rien sous un répertoire exclu ne peut être réinclus : avec private/ exclu, !private/ok.txt n'a aucun effet. Pour exclure le contenu d'un répertoire tout en en gardant une partie, excluez /private/* et réincluez !/private/ok.txt.

Règles > : confier la requête à l'application

.oxphpdeny
> /storage/invoices/ > *.map

Une requête qui correspond à une règle > n'est pas servie depuis le disque. Elle part vers le script d'entrée avec REQUEST_URI intact, exactement comme une requête pour un fichier absent, et l'application peut vérifier une session et diffuser le fichier elle-même. Le script d'entrée est le contrôleur frontal (ENTRY_FILE=index.php) ou le worker. Les espaces et tabulations entre > et le motif sont autorisés ; \> est un > littéral. Une règle > ne peut pas être niée (!>x est une erreur), et un > seul est une erreur.

Une règle de refus s'applique toujours sous un répertoire > : avec > /storage/invoices/ et *.sql, /storage/invoices/dump.sql est refusé, quelle que soit la ligne qui vient en premier. Une règle ! qui lève le refus laisse le fichier au répertoire >, comme dans git : ajoutez !/storage/invoices/dump.sql après *.sql et le script d'entrée reçoit cette requête. ! ne réinclut rien sous un répertoire >, pas plus que sous n'importe quel répertoire exclu, si bien que le fichier n'est toujours pas servi depuis le disque.

Warning

Les règles > nécessitent un script d'entrée. En modes Traditionnel et SPA, elles font échouer le démarrage.

Différences avec .gitignore

  • Une ligne mal formée fait échouer le démarrage, avec le chemin du fichier et le numéro de ligne, tout comme un fichier qui n'est pas en UTF-8. git ignore silencieusement de telles lignes.
  • {a,b} est littéral. Les accolades ne correspondent qu'à elles-mêmes, comme dans git.
  • Une classe qui nomme / fait échouer le démarrage, que ce soit directement ([-/]) ou par une plage qui l'enjambe, comme [+-0]. git l'accepte et ne lui fait jamais correspondre / ; écrivez le séparateur hors de la classe.
  • Une classe POSIX, une barre oblique inverse dans une classe ou un - juste après une plage fait échouer le démarrage : [[:digit:]], [\]], [a-b-c]. git interprète chacun à sa façon. Écrivez plutôt la plage ([0-9]), placez ] en premier pour le faire correspondre, et placez un - littéral en premier ou en dernier ([]-], [-a-c]).
  • Une plage à l'envers fait échouer le démarrage, comme [z-a] ou [a--]. git la lit comme son seul premier caractère, si bien que x[z-a] ne couvre que xz ; écrivez ce caractère, ou placez la borne inférieure en premier.
  • Une classe contenant un caractère hors ASCII fait échouer le démarrage, par exemple [äÄ]. git compare une classe à un seul octet, comme le serveur, et un tel caractère en occupe deux ou plus : /[äÄ]dmin ne couvrirait ni /ädmin ni /Ädmin, et /[äÄ]* couvrirait /öffnungszeiten, qui commence par le même octet. Mettez * à sa place.
  • Un / échappé fait échouer le démarrage (**\/secret.txt, \/vendor). git lit **\/secret.txt comme secret.txt sous au moins un répertoire (pas /secret.txt lui-même), ce que le serveur ne ferait pas, et \/vendor ne correspond à aucun chemin. Un séparateur n'a jamais besoin d'être échappé : écrivez /.
  • Une ligne qui couvre autre chose que ce qu'elle semble dire fait échouer le démarrage, c'est-à-dire une ligne qui commence par une espace ou une tabulation, se termine par une tabulation non échappée, contient // à un endroit quelconque ou contient un octet NUL. git accepte chacune d'elles. Les blancs à l'une ou l'autre extrémité font partie du nom : > /storage/invoices/, indentée, est une règle de refus pour un chemin commençant par une espace, et non une règle >. Échappez-les avec \ lorsqu'un nom commence ou se termine réellement par l'un d'eux. //vendor et vendor// ne correspondent à aucun chemin. git arrête un motif à un NUL, si bien que vendor, suivi d'un NUL et d'autre texte, couvre /vendor dans git, là où le serveur lirait la ligne entière et ne couvrirait qu'un nom contenant ce NUL. Un fichier enregistré en UTF-16 contient un NUL après chaque caractère ASCII : enregistrez-le en UTF-8.
  • Une ligne faite d'espaces et de tabulations est vide. git lit une ligne contenant une tabulation comme un motif, pour un nom composé de ces blancs.
  • Les règles de refus et les règles > ignorent la casse des lettres ASCII ; les règles ! non. Le serveur exécute /uploads/shell.PHP comme un script PHP sur n'importe quel système de fichiers, et un système de fichiers insensible à la casse (macOS, Windows) sert /VENDOR/autoload.php depuis vendor/, si bien qu'une règle qui ne correspondrait qu'à une seule graphie laisserait passer les autres : *.php couvre shell.PHP, et vendor/ couvre /Vendor/. Une règle ! ne réinclut que la graphie qu'elle nomme (avec * et !/index.php, /INDEX.PHP reste refusé), si bien qu'ignorer la casse ne permet jamais au serveur de servir depuis le disque un chemin que la correspondance exacte lui aurait interdit. Pour qu'il en reste ainsi, une classe négative dans une règle de refus ou une règle > ne peut pas nommer une lettre ASCII ; si elle en nomme une, le démarrage échoue : en ignorant la casse, [!i] exclurait aussi I, et /[!i]*.php laisserait exécuter /Info.php. Faites correspondre n'importe quel caractère à cet endroit et réincluez l'exception, qu'une règle ! fait alors correspondre exactement : /*.php et !/i*.php. git compare en respectant la casse, sauf si core.ignorecase est défini.
  • Quelques lettres hors ASCII sont lues comme de l'ASCII. Un système de fichiers insensible à la casse (macOS) les lit ainsi et sert license.txt pour /licenſe.txt : ſ (s long) comme s, ß et ẞ comme ss, le symbole kelvin (U+212A) comme k, les ligatures ff fi fl ffi ffl ſt st comme les lettres qu'elles lient, et le point d'interrogation grec (U+037E) et la varia (U+1FEF) comme ; et `. Les règles de refus et les règles > les lisent ainsi elles aussi, dans la requête comme dans la règle : /license.txt couvre /licenſe.txt, et /straße/ couvre /strasse/. Une règle couvre toujours ce qu'elle couvrait telle qu'écrite, et les règles ! ne lisent ces lettres que telles qu'écrites, si bien qu'un refus ne peut que s'élargir.
  • Les autres lettres hors ASCII ne correspondent que telles qu'écrites, et il en va de même pour chaque forme Unicode d'un nom. Une règle pour /ädmin ne couvre pas /Ädmin, et une règle pour café.txt écrite avec un é précomposé ne couvre pas cafe suivi d'un accent combinant (U+0301). La plupart des systèmes de fichiers Linux font de chaque graphie un fichier distinct ; la question se pose donc sous macOS, qui traite chacune des deux paires comme un seul fichier, y compris à travers un montage bind de macOS vers Docker Desktop. Là, une requête pour l'autre graphie est servie depuis le disque. Mettez * à la place de ces lettres (/caf*.txt), puisque ? et une classe correspondent à un seul octet, ou gardez ces fichiers hors de DOCUMENT_ROOT.
  • Au plus 256 règles, chacune d'au plus 128 octets. Une règle qui dépasse l'une ou l'autre limite fait échouer le démarrage avec son numéro de ligne (voir Limites).
  • Un seul fichier, au sommet de DOCUMENT_ROOT, lu au démarrage. Les fichiers des sous-répertoires ne sont pas lus. Ce doit être un fichier ordinaire d'au plus 1 MiB ; un lien symbolique vers un tel fichier convient. Un FIFO ou un périphérique fait échouer le démarrage sans être lu, et un fichier plus gros le fait échouer une fois son premier MiB lu.

Ce qui est comparé

Les règles sont comparées au chemin de la requête après le décodage des pourcentages et la normalisation. Les segments vides sont supprimés, si bien que //vendor/x, /vend%6Fr/x et /vendor%2Fx deviennent tous /vendor/x. Un segment commençant par . (., .., .git) n'atteint jamais les règles : la requête est refusée d'emblée avec un simple 404, sans PHP_DENY_FALLBACK et sans être comptée. L'exception est un /.well-known/ initial (RFC 8615), que le blocage des chemins commençant par un point laisse passer et que les règles voient comme n'importe quel autre chemin.

Une requête est une requête de répertoire lorsque son chemin décodé se termine par / (/admin/, /admin%2F, /admin//). Une règle avec un / final ne correspond qu'à ces requêtes, et à tout ce qui se trouve en dessous :

Règle /admin /admin/ /admin/x /admin/x/y
admin/ — refusé refusé refusé
admin ou /admin refusé refusé refusé refusé
admin/*, admin/x — — refusé refusé

Le / seul ne correspond jamais, si bien qu'un fichier en liste d'autorisation sert toujours la racine du site :

.oxphpdeny
* !/index.php !/assets/ !/assets/** !/.well-known/ !/.well-known/**
ACME HTTP-01

Conservez les lignes /.well-known/ si le site renouvelle son certificat TLS via ACME HTTP-01. Sans elles, /.well-known/acme-challenge/<token> est refusé.

Deux autres lectures d'une requête sont également jugées, et un refus selon l'une ou l'autre est maintenu :

  • Une requête de répertoire est aussi jugée comme le fichier qu'elle nomme. Le routeur supprime la barre oblique finale et peut servir /secret.txt/ comme le fichier secret.txt.
  • En mode Traditionnel, un segment .php suivi d'une suite de chemin est jugé comme le script qu'il nomme. /secret.php/x.js exécute secret.php avec le PATH_INFO /x.js : il est donc refusé partout où /secret.php l'est.

Dans les deux cas, !*/, la manière gitignore de réinclure tous les répertoires, ne peut pas faire revenir un fichier refusé. Dans une liste d'autorisation, réincluez un répertoire sans la barre oblique (!/docs) pour que /docs/ lui-même reste accessible. Aucun autre fichier vers lequel le routeur résout une requête n'est jugé, comme le montre la section suivante.

Le chemin de la requête, pas le fichier

Les règles voient le chemin demandé par le client, pas le fichier vers lequel le routeur le résout. Une requête qui se résout en un autre fichier est jugée sur son propre chemin :

  • /admin avec la règle admin/ : en mode Traditionnel, le routeur répond à /admin avec admin/index.php, et /admin n'est pas une requête de répertoire.
  • /uploads/ avec la règle uploads/*.php : l'index de répertoire uploads/index.php s'exécute, car le chemin de la requête est /uploads/.
  • /x.php/y.png (PATH_INFO) exécute x.php bien que le chemin se termine par .png. Le script est nommé dans le chemin, si bien que le mode Traditionnel juge aussi la requête comme /x.php, comme décrit plus haut.
Tip

Pour fermer un répertoire, écrivez-le sans barre oblique finale (/admin). Pour empêcher l'exécution de .php dans les répertoires accessibles en écriture, utilisez aussi PHP_DENY_PATHS : il vérifie également le script vers lequel une requête se résout.

Modes de routage

Mode de routage Règles de refus Règles >
Traditionnel (sans ENTRY_FILE) Oui Erreur de démarrage : il n'y a pas de script d'entrée
SPA (ENTRY_FILE=index.html) Oui Erreur de démarrage, car index.html ne peut pas prendre en charge une requête
Framework (ENTRY_FILE=index.php) Oui Contrôleur frontal
Worker (WORKER_MODE_ENABLED=true) Oui, avec un repli par code de statut Point d'entrée du worker

Repli

Une règle de refus répond avec PHP_DENY_FALLBACK, le réglage qu'utilise PHP_DENY_PATHS : un statut HTTP (404 par défaut ; se combine avec ERROR_PAGES_DIR) ou un script PHP à l'intérieur de DOCUMENT_ROOT. Le script reçoit OXPHP_DENIED_PATH (le chemin de la requête, avec un / initial) et OXPHP_DENIED_PATTERN (la règle telle qu'écrite dans le fichier, par exemple /composer.*).

Le script peut se trouver sous un chemin que le fichier refuse. /_security/ le soustrait aux requêtes directes, et c'est le script lui-même qui répond à une requête directe le visant, comme à un refus. PHP_DENY_FALLBACK n'est lu que si le fichier contient au moins une règle de refus (ou si PHP_DENY_PATHS est en vigueur, en modes Traditionnel et SPA).

Note

En mode Worker, le repli doit être un code de statut : un thread worker n'exécute rien d'autre que le point d'entrée du worker.

Lorsque .oxphpdeny et PHP_DENY_PATHS correspondent tous deux à une requête, c'est .oxphpdeny qui y répond.

Observabilité

  • La métrique oxphp_path_deny_total compte les requêtes refusées par une règle. Les règles > relèvent du routage, pas du refus, et ne sont pas comptées. oxphp_php_deny_total continue de ne compter que PHP_DENY_PATHS ; une requête refusée par les deux est comptée une seule fois, dans oxphp_path_deny_total.
  • Sur /config, l'entrée deny_file donne le nombre de règles par action et le type de repli, jamais les règles elles-mêmes (voir Serveur interne).
  • Le journal de démarrage indique loaded 5 rules (3 deny, 1 entry, 1 allow) from /var/www/html/public/.oxphpdeny. Avec LOG_LEVEL=debug, une ligne oxphpdeny rule par règle montre comment elle a été lue : numéro de ligne, action, motif tel qu'écrit et glob vers lequel il a été compilé.
  • Pour chaque requête, chaque refus journalise request denied by .oxphpdeny au niveau debug, avec le chemin, la règle et sa ligne. Une requête refusée pour sa profondeur ou sa longueur journalise request refused: path too deep or too long to match against .oxphpdeny au niveau debug, avec son nombre de segments et sa longueur ; elle n'est pas comptée.

Erreurs de démarrage

Condition Résultat
Pas de .oxphpdeny Fonctionnalité désactivée ; rien ne change
Fichier illisible ou non UTF-8, ou lien symbolique dont la cible est absente Erreur de démarrage
Ligne mal formée Erreur de démarrage avec <path>: line N: <message>
Plus de 256 règles, ou un motif de plus de 128 octets Erreur de démarrage avec le numéro de ligne
Règle > en mode Traditionnel ou SPA Erreur de démarrage
Mode Worker, une règle de refus et un script comme PHP_DENY_FALLBACK Erreur de démarrage
PHP_DENY_FALLBACK invalide et au moins une règle de refus Erreur de démarrage
Fichier vide, uniquement des commentaires ou uniquement des règles ! Rien à refuser : le routage est inchangé, limite de profondeur comprise ; /config montre le fichier comme chargé, avec ses nombres de règles

oxphp config --check signale les erreurs du fichier avant un déploiement (config: INVALID). Les vérifications du repli s'exécutent lorsque le serveur construit son routage, au démarrage.

Limites

  • La profondeur et la longueur du chemin sont plafonnées. Avec au moins une règle de refus ou une règle >, un chemin de requête de plus de 64 segments de profondeur, ou dont le nombre de segments multiplié par la longueur dépasse 128 KiB, reçoit un 404 sans être comparé. La correspondance revérifie chaque répertoire parent et parcourt le chemin de chacun, si bien que son coût croîtrait sinon avec les deux. Un chemin d'au plus 2 KiB n'est refusé que pour sa profondeur ; au-delà, le budget est épuisé dès 16 segments à 8 KiB, ou 3 à environ 43 KiB.
  • Un fichier contient au plus 256 règles, chaque motif faisant au plus 128 octets, sans compter un ! ou un > initial. Un chemin de requête est comparé aux règles une par une, et à chaque répertoire parent aussi, si bien que ces limites et celle ci-dessus bornent le coût d'une requête. Un fichier typique prend bien moins d'une milliseconde par requête. Un fichier aux deux limites, conçu pour être lent (256 longues règles commençant par /x/**/*, face à des chemins construits pour leur correspondre presque), prend environ 40 ms par requête et conserve environ 50 MB d'état de correspondance par thread worker Tokio.
  • Pour la sensibilité à la casse, voir Différences avec .gitignore.
  • Il n'y a pas de rechargement. Redémarrez pour appliquer une modification.

Exemples

Une application héritée servie depuis la racine de son projet :

.oxphpdeny
/vendor /composer.* /config *.sql *.sql.gz *.bak *.log

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
Écris un fichier `.oxphpdeny` pour l'application de ce dépôt. Lis la page `security/oxphpdeny` via le serveur MCP de la documentation OxPHP. Détermine quel répertoire est `DOCUMENT_ROOT` et quel mode de routage utilise le déploiement, puis trouve ce qui, sous ce répertoire, ne devrait jamais être servi — `vendor/`, `composer.*`, fichiers de configuration, dumps de base de données, sauvegardes, journaux, source maps — et lesquels de ces chemins l'application sert elle-même et ont plutôt besoin d'une règle `>`. Donne-moi le fichier avec un commentaire sur chaque règle, et liste ce qu'il ne peut pas couvrir parce que les règles comparent le chemin de la requête et non le fichier vers lequel il se résout.
prompt
Porte vers `.oxphpdeny` les règles de refus de mon ancienne configuration de serveur web. Lis la page `security/oxphpdeny` via le serveur MCP de la documentation OxPHP. Trouve dans ce dépôt les blocs `location` nginx qui font `deny all` ou `return 403`/`404`, ainsi que les règles `.htaccess` qui refusent l'accès, et traduis chacun en une ligne `.oxphpdeny`. Dis-moi lesquels le blocage des chemins commençant par un point couvre déjà et lesquels le fichier ne peut pas exprimer.
prompt
Passe en revue le fichier `.oxphpdeny` de ce dépôt à la recherche de failles. Lis la page `security/oxphpdeny` via le serveur MCP de la documentation OxPHP. Confronte chaque règle à la façon dont cette application est déployée : répertoires écrits avec une barre oblique finale qu'un index de répertoire atteint encore, noms contenant des lettres hors ASCII que macOS sert sous une autre graphie, répertoires accessibles en écriture qui ont aussi besoin de `PHP_DENY_PATHS`, et liste d'autorisation qui bloquerait `/.well-known/acme-challenge/`. Donne-moi le fichier corrigé et dis ce que ferme chaque modification.

Voir aussi

Une erreur ? Signalez-la →