ADR-144 — Descellement additif couche-0-v1.16 : fchmodat2 (durcissement anti-TOCTOU des permissions des sockets streamlocal)
Statut : Accepté (2026-07-31, autorisation BDFL explicite — « voie 2 » : merger la revue #566 telle quelle, puis durcir). RFC de structure (ADR-015). Applique le modèle de re-sceau additif de la couche 0 (ADR-051, ADR-085, ADR-126).
Catégorie : descellement additif de la couche 0 scellée (couche-0-v1.15 →
couche-0-v1.16), avec additif couche 1 joint (couche-1-v3.8 → couche-1-v3.9).
Aucun retrait, aucune signature modifiée ; seule la surface grandit.
Contexte
ADR-117 inc.c.1 et inc.c.2 ont introduit les
redirections vers un socket Unix : -L /local.sock:… (client) et
-R /distant.sock:… (serveur). Dans les deux cas, le processus crée un nœud de
système de fichiers puis doit restreindre ses permissions (0600) avant que ce
nœud n’accepte des connexions.
Ce chmod doit se faire par chemin : fchmod(fd) sur le descripteur d’un socket
Unix modifie l’inode sockfs, pas le nœud que le bind a créé. Vérifié
empiriquement : l’appel réussit et le chemin garde son mode d’origine — pire qu’une
erreur, un faux positif silencieux.
Or un chmod par chemin re-résout le chemin. Il restait donc une micro-fenêtre
TOCTOU locale entre le bind et le chmod : un attaquant ayant droit d’écriture sur
le répertoire conteneur (et en l’absence de sticky bit) pouvait substituer un lien
symbolique à notre nœud et nous faire poser 0600 sur un fichier tiers. Les revues
#566 (serveur -R) et #563 (client -L) l’ont documentée comme subie, faute
de primitive.
La cause est un piège d’API bien connu : le syscall noyau fchmodat ne prend que
trois arguments (dirfd, path, mode). Le flags de la signature C est ajouté
par la libc et n’atteint jamais le noyau : AT_SYMLINK_NOFOLLOW y est
silencieusement ignoré. C’est exactement le piège faccessat / faccessat2, déjà
traité dans air-sys-syscall par le routage vers le syscall à 4 arguments.
fchmodat2(2) (Linux ≥ 6.6, numéro 452 sur x86_64 comme sur aarch64) est la
variante à 4 arguments qui honore ce drapeau. Air cible Linux ≥ 6.12
(ADR-004) : elle est donc toujours disponible.
L’ajouter est un additif à une couche scellée, d’où cet arbitrage BDFL.
Décision
1. Couche 0 — couche-0-v1.16
- AJOUT
air_sys_types::fs::ChmodFlags(bitflags,i32), avec la seule variante utile :SYMLINK_NOFOLLOW = 0x100. Un type par famille de syscall, commeAccessFlags/UnlinkFlags/LinkFlags— pas d’entier magique (ADR-021). - AJOUT
air_sys_syscall::fs::fchmodat2(dirfd, path, mode, flags) -> Result<(), Errno>(syscall4,nr::FCHMODAT2 = 452sur les deux arches — vérifié contreasm/unistd_64.hetasm-generic/unistd.h, et re-vérifié en CI par le gatecheck-syscalls).EINTRremonté, jamais retenté (ADR-021 conv. 2). fchmodat(3 arguments) est conservé inchangé : c’est lechmodqui suit le lien, sémantique légitime et socle destd::fs::set_permissions.
Le drapeau porte une garantie, pas seulement un comportement. Linux ne sait pas
modifier les permissions d’un lien symbolique : avec SYMLINK_NOFOLLOW, un chemin
substitué fait échouer l’appel en EOPNOTSUPP. L’appelant obtient donc, en plus du
chmod, la preuve que le chemin désignait bien le nœud attendu.
2. Couche 1 — couche-1-v3.9
- AJOUT
air_filesystem::AirFileManager::change_mode_nofollow(directory, path, mode)—fchmodat2+SYMLINK_NOFOLLOW, erreur mappée enAirError. Jumeau strict dechange_mode, qui reste la variante « suit le lien ».
3. Couche 2 — les deux appelants sont durcis
air_sshd::streamlocal_forward(serveur-R) : lechmodentrebindetlistenpasse àchange_mode_nofollow. Un échec est traité comme unbindraté : leSocketPathGuardsupprime le nœud, lelistenn’a jamais lieu, le client reçoit unREQUEST_FAILURE. Nous n’écoutons jamais sur un socket dont nous n’avons pas pu prouver qu’il était le nôtre.air_sshd::client_forward::bind_one_streamlocal(client-L) : changement de posture assumé — le socket local était laissé à0777 & ~umask(la doc s’en remettait au seul répertoire conteneur). Il est désormais ramené à0600entrebindetlisten, par le même appel no-follow ; un échec n’ouvre pas la redirection et remonte l’erreur. C’est le comportement d’OpenSSH (StreamLocalBindMask 0177), et un socket-Lmondialement connectable est un pont vers l’hôte distant utilisable par n’importe quel utilisateur local. Un partage par groupe volontaire n’est plus obtenu par défaut ; il devra, le cas échéant, être une option explicite.
Conséquences
- Tag git
couche-0-v1.16(+couche-1-v3.9) posé par le superviseur avec le BDFL, sur le commit de merge, après CI verte. Aucun tag n’est posé par cet incrément. - La fenêtre TOCTOU des revues #566 / #563 est fermée, et la documentation des modules concernés le dit désormais explicitement (elle disait « subie »).
- Posture strictement meilleure qu’OpenSSH sur ce point : OpenSSH s’en remet à
StreamLocalBindMasket conserve la fenêtre. - Couverture 100 % couches 0/1 :
fchmodat2est exercé on-target sur ses deux bras (succès sur un fichier régulier, mode relu à0600;EOPNOTSUPPsur un lien symbolique, cible vérifiée intacte ;ENOENTsur chemin absent), etchange_mode_nofollowsur son cas nominal et sa substitution hostile. - Noyau : sur un noyau < 6.6,
fchmodat2rendENOSYS— hors cible supportée (ADR-004). L’appelant échoue alors proprement (refus du forward) plutôt que de poser des permissions sans garantie.
Alternatives rejetées
fchmod(fd)sur le descripteur du socket. Ne fonctionne pas : le descripteur désigne l’inode sockfs, pas le nœud de système de fichiers créé par lebind. Prouvé empiriquement — l’appel réussit et ne change rien, ce qui en fait la pire des options (aucune erreur à traiter).- Émuler le no-follow via
/proc/self/fd/N. Lourd et fragile : exige unopen(O_PATH|O_NOFOLLOW)du chemin, la présence de/proc(que le durcissement du démon peut retirer), et resterait unchmodpar chemin sur un chemin proc. Beaucoup de mécanique pour émuler un syscall dont Air a la version native. - Poser un
umaskrestrictif autour dubind. Rejeté (déjà par ADR-117) :umaskest un état global au processus, qu’un serveur multi-tâche n’a pas à modifier en passant. - Garder la fenêtre et se contenter de la documenter. C’était l’état d’ADR-117 faute de primitive ; le BDFL a tranché pour la fermer (« voie 2 »).
Suite
- Tag
couche-0-v1.16/couche-1-v3.9après merge et CI verte (superviseur + BDFL). - Note de re-sceau dans
etat-avancement.mdetINDEX.md.