Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.15couche-0-v1.16), avec additif couche 1 joint (couche-1-v3.8couche-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, comme AccessFlags / UnlinkFlags / LinkFlags — pas d’entier magique (ADR-021).
  • AJOUT air_sys_syscall::fs::fchmodat2(dirfd, path, mode, flags) -> Result<(), Errno> (syscall4, nr::FCHMODAT2 = 452 sur les deux arches — vérifié contre asm/unistd_64.h et asm-generic/unistd.h, et re-vérifié en CI par le gate check-syscalls). EINTR remonté, jamais retenté (ADR-021 conv. 2).
  • fchmodat (3 arguments) est conservé inchangé : c’est le chmod qui suit le lien, sémantique légitime et socle de std::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 en AirError. Jumeau strict de change_mode, qui reste la variante « suit le lien ».

3. Couche 2 — les deux appelants sont durcis

  • air_sshd::streamlocal_forward (serveur -R) : le chmod entre bind et listen passe à change_mode_nofollow. Un échec est traité comme un bind raté : le SocketPathGuard supprime le nœud, le listen n’a jamais lieu, le client reçoit un REQUEST_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é à 0600 entre bind et listen, 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 -L mondialement 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 à StreamLocalBindMask et conserve la fenêtre.
  • Couverture 100 % couches 0/1 : fchmodat2 est exercé on-target sur ses deux bras (succès sur un fichier régulier, mode relu à 0600 ; EOPNOTSUPP sur un lien symbolique, cible vérifiée intacte ; ENOENT sur chemin absent), et change_mode_nofollow sur son cas nominal et sa substitution hostile.
  • Noyau : sur un noyau < 6.6, fchmodat2 rend ENOSYS — 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 le bind. 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 un open(O_PATH|O_NOFOLLOW) du chemin, la présence de /proc (que le durcissement du démon peut retirer), et resterait un chmod par chemin sur un chemin proc. Beaucoup de mécanique pour émuler un syscall dont Air a la version native.
  • Poser un umask restrictif autour du bind. Rejeté (déjà par ADR-117) : umask est 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.9 après merge et CI verte (superviseur + BDFL).
  • Note de re-sceau dans etat-avancement.md et INDEX.md.