Gitfed
bastien-mrq/gitfed / docs / security / AUDIT-2026-07-29b.md
AUDIT-2026-07-29b.md Code Aperçu

Audit de sécurité — merge requests (2026-07-29, suite)

  • Périmètre : uniquement la fonctionnalité merge requests (internal/store/merge_requests.go, la plomberie git ajoutée dans internal/gitexec pour les branches/fusions, admin.Ops/adminrpc correspondants, cmd/gitfed-web/handlers_merge_requests.go).
  • Nature : revue de code adversariale, avec exploitation réelle en bac à sable (pas seulement une lecture de code) — chaque faille listée ci-dessous a été effectivement déclenchée contre une instance locale avant d'être corrigée, pas seulement supposée théorique.
  • État : les correctifs identifiés ci-dessous sont déjà appliqués dans ce commit, avec un test de régression qui reproduit l'exploit exact pour C1 (internal/gitexec/gitexec_test.go) et pour L3 (internal/admin/admin_test.go).

1. Synthèse

ID Sévérité Titre Statut
C1 🔴 Critical Injection d'argument via les hash de commit et noms de branche ✅ corrigé
L3 🟡 Low Fermer une MR déjà fusionnée écrase silencieusement son statut ✅ corrigé
L4 🟡 Low Titre/description/commentaire non plafonnés côté serveur ✅ corrigé

C1 est la découverte importante de cet audit — un contournement complet du modèle de menace habituel (aucune authentification requise, aucun accès en écriture nécessaire) menant à une écriture de fichier arbitraire, avec un chemin réaliste vers l'exécution de code. Le reste de la surface merge requests (autorisation, isolation git, gestion des conflits/races) a résisté à l'examen — voir §3.


2. Détail

🔴 C1 — Injection d'argument via les hash de commit et noms de branche

Fichiers : internal/gitexec/gitexec.go (ShowCommit, CommitDiff, BranchDiff, branchFiles, commitFiles, withScratchWorktree, CheckMergeable, MergeBranches)

Le problème. Chacune de ces fonctions passe un hash de commit ou un nom de branche comme dernier argument d'une commande git : git show --format=... <hash>, git diff --no-color <target>...<source>, git worktree add --detach <dir> <base>, git merge --no-ff -m <msg> <source>. Le porcelain git (git branch, git checkout -b) refuse côté client tout nom de ref commençant par - (check-ref-format) — mais ce n'est qu'une validation client. Un git update-ref direct sur le dépôt bare, ou un push utilisant directement une refspec, crée la ref sans broncher. Une fois cette ref créée, elle est renvoyée par ListBranches exactement comme n'importe quelle autre — rien ne la distingue dans l'interface.

Preuve de faisabilité (réellement exécutée, pas seulement décrite).

# Contourne check-ref-format en écrivant la ref directement sur le bare repo :
git --git-dir=demo.git update-ref 'refs/heads/--output=/tmp/poc.txt' <commit>

Puis, sans authentification, sans accès en écriture, juste une requête GET vers un dépôt public :

GET /repo-commit/testuser/demo?hash=--output=/tmp/gitfed-commit-poc.txt

hash provient directement du paramètre de requête, sans aucune validation qu'il ressemble à un hash. git show a interprété --output=/tmp/gitfed-commit-poc.txt comme son propre indicateur -o/--output=<fichier> ("Output to a specific file instead of stdout.") plutôt que comme une révision — et a effectivement créé le fichier /tmp/gitfed-commit-poc.txt sur le disque du serveur.

Impact. Une écriture de fichier arbitraire à un chemin choisi par l'attaquant, à l'endroit où gitfed-server (uid 1000, propriétaire de tout /data) a le droit d'écrire — ce qui inclut hooks/post-receive dans n'importe quel dépôt bare de l'instance. Un hook git s'exécute automatiquement à chaque git push suivant sur ce dépôt : c'est un chemin réaliste vers l'exécution de code arbitraire en tant que processus gitfed-server, déclenchable par le tout premier visiteur anonyme d'une instance publique, aucun compte, aucune clé SSH, aucun accès en écriture nécessaire. La variante par branche (nécessite un accès en écriture pour créer la ref malveillante, puis ouvrir une merge request avec comme branche source ou cible) a été vérifiée de la même manière via branchFiles/BranchDiff/CheckMergeable/MergeBranches.

Correctif. --end-of-options ajouté juste avant chaque argument de ce type — un indicateur générique reconnu par les commandes basées sur parse-options de git (2.24+, largement disponible), qui force tout ce qui suit à être traité comme un argument positionnel, jamais comme une option. Vérifié explicitement que ça préserve le comportement correct pour une révision légitimement préfixée par un tiret (contrairement à --, qui aurait réinterprété l'argument comme un chemin de fichier au lieu d'une révision, cassant la sémantique de plage A...B). Testé en conditions réelles :

  • Un hash/nom de branche légitime continue de fonctionner exactement comme avant (diff/merge produits correctement).
  • Le même hash malveillant qu'en preuve de faisabilité ne crée plus aucun fichier ; la page correspondante répond 200 proprement au lieu d'un 500 (git rejette la révision, gitfed le gère normalement).
  • Test de régression TestArgumentInjectionIsBlocked (internal/gitexec/gitexec_test.go) : reproduit l'exploit exact contre chacune des fonctions concernées et échoue si le correctif est retiré (vérifié en désactivant temporairement --end-of-options sur CommitDiff — le test échoue bien en détectant le fichier créé).

🟡 L3 — Fermer une MR déjà fusionnée écrase silencieusement son statut

Fichier : internal/admin/admin.go (CloseMergeRequest)

Le problème. CloseMergeRequest fixait Status = MRClosed inconditionnellement, sans vérifier le statut courant. Une MR déjà fusionnée pouvait donc être "fermée" — le commit de fusion reste bien réel dans la branche cible, mais l'enregistrement de gitfed se met à afficher « Fermée sans fusion », un mensonge pur et simple sur l'historique.

Preuve de faisabilité. MR réellement fusionnée (branche cible avancée, vérifié par git log), puis POST /repo-mr-close/{repo} avec son numéro : la page de détail affiche ensuite le badge « fermée » et « Fermée sans fusion », alors que le commit de fusion est toujours présent dans l'historique de la branche.

Impact. Intégrité de la piste d'audit, pas d'élévation de privilège — n'importe quel collaborateur en écriture peut se donner un faux historique (« ma MR a été rejetée » alors qu'elle a bien été intégrée), utile pour tromper une revue ou masquer qu'un changement est passé.

Correctif. CloseMergeRequest est maintenant un no-op si le statut n'est plus MROpen, à l'image de la même garde déjà présente sur MergeMergeRequest. Test de régression TestCloseMergeRequestDoesNotOverwriteMerged (internal/admin/admin_test.go).


🟡 L4 — Titre/description/commentaire non plafonnés côté serveur

Fichier : cmd/gitfed-web/handlers_merge_requests.go

Les attributs maxlength du formulaire (200 pour le titre, 4000 pour la description/le commentaire) ne sont que côté client — un POST direct les contourne trivialement, comme vérifié par exploitation réelle (titre de 300 caractères accepté avant correctif). Contrairement au plafond déjà en place sur les notifications fédérées (accessible à n'importe quelle instance distante sans relation préalable), créer une MR ou commenter exige déjà d'être authentifié et d'avoir accès en lecture/écriture au dépôt — le risque est donc plus faible (abus par un collaborateur déjà de confiance, pas par un tiers anonyme), ce qui justifie un correctif proportionné : une limite de longueur serveur, sans plafond sur le nombre de MR/commentaires par dépôt.

Correctif. mrTitleMaxLen/mrTextMaxLen (200/4000, identiques aux attributs maxlength) revérifiés côté serveur dans handleMRCreate et handleMRComment. Vérifié : un titre de 300 caractères est maintenant rejeté avec le message d'erreur habituel, un titre normal continue de fonctionner.


3. Contrôles vérifiés et jugés sains

Surface Constat
Autorisation de fusion/fermeture Passe par CheckAccess(repo, principal, RoleWrite), le même contrôle qu'un git push par SSH — vérifié qu'un utilisateur sans accès en écriture ne voit ni bouton Fusionner ni bouton Fermer, et qu'un POST direct sans cet accès reçoit un 404.
Fuite de MR d'un dépôt privé canView revérifié dans handleMRDetail/handleMRList avant tout accès, même 404 uniforme que le reste de l'app — vérifié anonyme + utilisateur sans accès.
XSS via titre/description/commentaire Tout passe par html/template en échappement automatique, jamais de template.HTML sur ces champs — vérifié qu'aucun de ces trois n'est enrobé différemment des autres champs utilisateur déjà couverts par l'audit précédent.
CSRF sur les routes de mutation (/repo-mr-new, /repo-mr-merge, /repo-mr-close, /repo-mr-comment) Aucune de ces routes n'est dans la liste d'exemption de sameOriginPOST (seule /git-upload-pack l'est) — protégées par défaut.
Isolation de la fusion elle-même MergeBranches tourne dans un worktree jetable (git worktree add --detach), jamais sur l'état réel de la branche cible ; nettoyage (worktree remove --force) garanti par defer, vérifié qu'aucun worktree ne survit après une fusion en conflit.
Mise à jour du ref cible git update-ref refs/heads/<target> <nouveau> <ancien> est un compare-and-swap — un push concurrent qui déplace <target> entre le calcul de la fusion et cet appel fait échouer la mise à jour au lieu de l'écraser silencieusement. Le commit de fusion perdant devient orphelin (récupéré par un futur git gc), pas de corruption de données.
Double clic / fusion concurrente sur la même MR La vérification Status == MROpen se fait avant l'opération git (lente), mais comme la mise à jour du ref est elle-même atomique côté git, un seul des deux appels concurrents peut réellement faire avancer la branche ; le perdant reçoit une erreur serveur générique au lieu d'un message clair — une aspérité d'UX notée mais pas une faille de sécurité, la donnée stockée reste correcte.
Numérotation des MR (#1, #2, ...) Lecture + incrément + écriture dans une seule transaction bbolt.Update — bbolt garantit une seule transaction d'écriture à la fois sur toute la base, donc pas de race possible sur l'attribution du numéro même avec des créations concurrentes.

4. Note

C1 n'est pas une régression d'un contrôle qui existait avant et aurait été retiré — c'est une fonctionnalité (l'affichage détaillé de commit, puis les merge requests) ajoutée dans cette même session sans que ce chemin d'argument-injection ait été anticipé au moment de l'écrire. L'audit du 29/07 précédent (AUDIT-2026-07-29.md) avait bien vérifié l'absence d'injection d'argument sur repo.Path dans le clone HTTP — ce constat reste vrai, il portait simplement sur une surface différente de celle touchée ici, ajoutée après coup.