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 dansinternal/gitexecpour les branches/fusions,admin.Ops/adminrpccorrespondants,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
200proprement au lieu d'un500(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-optionssurCommitDiff— 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.