
Un utilisateur Docmost à faibles privilèges pourrait fournir un `attachmentId` de victime au point de terminaison de téléchargement générique et écraser la pièce jointe stockée d'une autre page dans le même espace de travail.
Un utilisateur Docmost à faibles privilèges pourrait fournir un attachmentId victime au point de terminaison de téléversement générique et écraser la pièce jointe stockée d'une autre page dans le même espace de travail.
J'ai identifié, divulgué de manière responsable et reproduit un défaut d'autorisation de Haute gravité dans Docmost, la plateforme collaborative de documentation open‑source.
Le site officiel de Docmost la présente comme un wiki sur site prêt pour l'entreprise avec 3M+ téléchargements, et affirme qu'elle est utilisée par des équipes d'organisations dont Vilnius City, Bechtle, le Gouvernement australien, la Croix‑Rouge et ETS Québec.
Le bogue vivait dans le chemin générique de téléversement de fichiers que Docmost utilise également pour les flux de sauvegarde/mise à jour de diagrammes.
J'examinais ce code avec une question très spécifique en tête :
Que se passe‑t‑il si le point de terminaison de téléversement prouve l'accès en modification sur une page, mais que la cible d'écrasement est sélectionnée avec un identifiant de pièce jointe distinct contrôlé par l'utilisateur ?
Dans ce cas, cette question a directement conduit à une véritable défaillance de liaison d'objet.
Docmost permettait à un appelant d'envoyer :
pageId pour une page qu'il était autorisé à modifier, etattachmentId appartenant à une page différente dans le même espace de travailLe serveur effectuait bien un contrôle de cohérence d'écrasement, mais la garde utilisait la mauvaise logique booléenne.
Cela signifiait que la requête pouvait passer l'autorisation et écraser la pièce jointe victime quand même.
Ce problème est devenu CVE-2026-34213.
Docmost : docmost/docmost
Avis : GHSA-89fp-2hch-j9gp
CVE : CVE-2026-34213
Corrigé dans : v0.71.0

pageId contrôlé par l'attaquant avec accès en modification -> victim attachmentId contrôlé par l'attaquant -> garde d'écrasement défaillante traite l'écrasement inter‑pages comme valide -> chemin de stockage reconstruit à partir du victim attachmentId -> les octets de l'attaquant remplacent le fichier victime -> la page victime continue de servir la pièce jointe modifiée
Docmost stocke les pièces jointes téléversées sous forme d'enregistrements en base de données plus des fichiers de sauvegarde dans le stockage.
Pour les téléversements normaux, le serveur crée un nouvel identifiant de pièce jointe et écrit un nouveau fichier.
Pour les flux de sauvegarde/mise à jour de diagrammes, cependant, le client réutilise intentionnellement un attachmentId existant afin que le même fichier de diagramme puisse être mis à jour sur place au lieu de générer un tout nouvel enregistrement de pièce jointe à chaque fois.
Ce comportement est légitime en soi.
Le problème est qu'il crée un chemin à haut risque :
Chaque fois qu'un point de terminaison mélange ces deux responsabilités, l'implémentation doit les lier exactement.
Docmost ne l'a pas fait.
Les points de terminaison mixtes création/mise à jour sont des endroits courants pour les bogues d'autorisation.
La raison est simple :
C'est exactement le schéma ici.
POST /api/files/upload validait que l'appelant pouvait modifier la page nommée par pageId.
Mais si attachmentId était également fourni, le serveur basculait vers un chemin d'écrasement et sélectionnait un enregistrement de pièce jointe existant séparément.
Cela posait la question de sécurité critique :
le chemin d'écrasement prouve‑t‑il que la pièce jointe sélectionnée appartient bien à la page autorisée ?
La réponse dans les versions vulnérables était non.
La cause racine était un contournement d'autorisation via une clé contrôlée par l'utilisateur, combiné à un bogue de logique booléenne dans la garde d'écrasement.
Le flux vulnérable ressemblait à ceci :
AttachmentController.uploadFile() lisait pageId à partir des données de formulaire multipart.validateCanEdit(page, user).attachmentId optionnel depuis la même requête.AttachmentService.uploadFile() chargeait la pièce jointe existante via cet identifiant fourni par l'attaquant.&& au lieu de rejeter sur toute non‑correspondance.La garde vulnérable était :
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
Cette condition ne rejetait la requête que si :
tous en même temps.
C'est l'inverse de ce qu'une garde d'écrasement devrait faire.
Dans le cas d'attaque réel, l'attaquant restait intentionnellement dans le même espace de travail.
Donc :
existingAttachment.workspaceId !== workspaceId était falseUne fois que cet opérande devenait false, toute la condition && s'évaluait à false, même si la pièce jointe appartenait à une page différente.
Ainsi, le serveur traitait un écrasement inter‑pages comme valide.
C'était la première moitié du bogue.
La seconde moitié est ce qui rend l'impact réel.
Après la vérification, le service reconstruisait le chemin de stockage de destination en utilisant le attachmentId et le nom de fichier fournis par l'attaquant :
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
Ensuite, sur le chemin de mise à jour, Docmost ne mettait à jour que les métadonnées mutables telles que :
fileSizeupdatedAtIl ne reliait pas la propriété à la page de l'attaquant.
Ainsi, la page victime continuait de pointer vers le même enregistrement de pièce jointe et le même identifiant de pièce jointe. Seuls les octets du fichier sous‑jacent changeaient.
C'est pourquoi ce n'était pas une simple non‑correspondance inoffensive.
C'était une primitive d'écrasement persistant non autorisé.
Ce n'était pas un bogue cosmétique ni un problème de collision de noms de fichiers.
L'attaquant n'avait pas besoin d'une condition de course. L'attaquant n'avait pas besoin de deviner un chemin aléatoire. L'attaquant n'avait pas besoin d'accès en écriture à la page victime.
Il avait seulement besoin :
À partir de là, il pouvait remplacer les octets du fichier stocké pour la pièce jointe d'une autre page tandis que la page victime continuait de référencer et de servir cette pièce jointe comme si de rien n'était.
C'est une défaillance directe d'intégrité.
En termes pratiques, l'attaquant pouvait :
Le point important est le suivant :
le serveur acceptait une cible d'écrasement choisie par l'attaquant, sans la lier à la page dont la permission d'édition avait effectivement été vérifiée.
Il s'agit d'une défaillance de contrôle d'accès, pas seulement d'une mauvaise hygiène booléenne.
L'exploit était particulièrement pratique pour les pièces jointes de diagrammes.
Le client de Docmost réutilise intentionnellement attachmentId pour les sauvegardes de diagrammes et utilise des noms de fichiers déterministes :
diagram.excalidraw.svgdiagram.drawio.svgC'est important car cela réduit les prérequis de l'attaquant.
Pour les pièces jointes génériques, l'attaquant a besoin à la fois :
Pour les diagrammes, le nom de fichier est déjà prévisible.
Donc si l'attaquant peut lire le contenu de la page victime, il peut souvent récupérer la seule pièce manquante dont il a besoin :
attachmentId victimeDans ma configuration de validation, j'ai utilisé exactement ce chemin :
C'était suffisant.
L'exploit traversait les limites de pages et les limites d'espaces à l'intérieur du même espace de travail, tout en satisfaisant la vérification erronée d'espace de travail.
J'ai validé le problème en direct sur Docmost v0.70.3 en utilisant un laboratoire jetable construit à partir de docmost/docmost:0.70.3, Postgres et Redis.
Le flux du PoC était :
attachmentId victime.POST /api/files/upload avec :
pageId = ID de la page attaquanteattachmentId = ID de la pièce jointe victimefile = fichier de remplacement contrôlé par l'attaquant utilisant le nom de fichier victimeLa forme minimale de la requête était :
POST /api/files/upload
Content-Type: multipart/form-data
pageId=<attackerPageId>
attachmentId=<victimAttachmentId>
[email protected];filename=diagram.excalidraw.svg
Le résultat observé en direct était :
019d18ae-b176-751c-8525-b5f3cede131d019d18ae-b15b-70e9-ac67-64948e87cc5e019d18ae-b12f-75ec-8c1c-5aff3ba6be9c200 OK686a0a0ede90ece1cbb975bb29304a6c3a90373a9c3ab2496345cf7ca59cc8fa
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
Remplacement de l'attaquant depuis une autre page
C'est une preuve complète d'écrasement de bout en bout, pas seulement une revue théorique du code source.
J'ai utilisé deux styles de preuve pendant le triage :
Le harnais autonome était utile pour isoler l'échec de logique booléenne.
Le PoC HTTP en direct était l'artefact le plus fort car il prouvait l'histoire complète de sécurité :
attachmentId victime était acceptéCette distinction compte dans les bogues de contrôle d'accès.
« la condition est fausse » ne suffit pas en soi.
« la condition est fausse, et l'application peut être pilotée de bout en bout vers un écrasement persistant non autorisé » est le cas complet.
Le correctif livré dans v0.71.0 a changé la garde d'écrasement de && à || :
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
Ce correctif est minimal, direct et correct pour le bogue qui a été signalé.
Il rétablit la bonne règle :
l'écrasement est autorisé uniquement lorsque la pièce jointe existante correspond exactement aux hypothèses de page/espace de travail/type autorisées.
Une fois que la garde rejette sur toute non‑correspondance :
C'était le bon type de correctif :
Juste un lien strict entre la page autorisée et la cible d'écrasement.
Il y a toujours une leçon d'ingénierie plus large ici :
les points de terminaison de téléversement génériques qui servent également des flux de mise à jour sur place devraient être traités comme des surfaces API à haut risque.
Même lorsque le bogue immédiat est corrigé, des conceptions plus solides à long terme sont :
Mais pour la vulnérabilité elle‑même, le correctif publié a fermé le problème central proprement.
Que le projet ait ajouté ou non ses propres tests privés autour du correctif, voici les cas qui comptent pour une couverture à long terme :
pageId et le même attachmentId doit réussirpageId différent et le même espace de travail doit échouerattachmentId étranger doit échouerLe but de ces tests n'est pas seulement l'exactitude.
C'est de verrouiller la liaison d'autorisation afin que de futures refontes « utiles » du téléversement ne rouvrent pas la même classe de bogue.
L'avis publié a classé ce problème comme :
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
Cela atterrit à 7.1 / Haute, ce qui est la bonne conclusion.
La métrique importante ici est l'Intégrité.
Ce n'était pas un bogue de métadonnées de bas niveau. L'attaquant contrôlait entièrement les octets de remplacement écrits dans le chemin de la pièce jointe d'une autre page, et la page victime continuait de servir l'objet modifié par la suite.
C'est exactement le type de falsification inter‑enregistrements stockée qui mérite une Intégrité Haute.
La disponibilité restant Faible a également du sens car corrompre un diagramme ou un document joint peut rendre le contenu victime inutilisable, mais l'impact principal reste une modification non autorisée plutôt qu'une interruption complète du service.
J'ai signalé le problème de manière privée via GitHub Security Advisories avec :
Le problème a été accepté par le mainteneur, attribué à CVE-2026-34213, et publié le 14 avril 2026.
L'avis public liste :
>= v0.3.0v0.71.0Cette histoire correspondait également à ma revue locale du code source : la logique d'écrasement vulnérable était présente dans la première version étiquetée que j'ai vérifiée dans la ligne vulnérable.
La leçon intéressante ici n'est pas simplement « utilisez || au lieu de && ».
C'est le symptôme.
La leçon plus profonde est :
si un champ contrôlé par l'utilisateur prouve l'autorisation et qu'un autre champ contrôlé par l'utilisateur sélectionne l'objet en cours de mise à jour, ces deux champs doivent être liés explicitement et exactement.
Cette règle apparaît partout :
Dès qu'un système dit :
il a créé une frontière de sécurité qui doit être appliquée avec des invariants de correspondance exacte.
Tout ce qui est plus souple que cela se transforme tôt ou tard en un bogue de clé contrôlée par l'utilisateur.
Ce problème renforce également un deuxième point facile à sous‑estimer :
les petites erreurs booléennes dans le code de garde peuvent avoir des conséquences de sécurité de premier ordre.
Une condition à trois clauses qui « semble raisonnable » à première vue a suffi à inverser le modèle de protection pour le chemin d'écrasement.
C'est pourquoi ces surfaces méritent une revue délibérée plutôt qu'une confiance décontractée.
pageId fourni par l'appelant, mais la sélection de la cible d'écrasement utilisait un attachmentId distinct fourni par l'appelant.v0.71.0 changeait correctement la garde pour rejeter sur toute non‑correspondance.Cette vulnérabilité ne concernait pas un comportement de stockage exotique.
Elle concernait un chemin de mise à jour qui faisait trop confiance à un identifiant d'objet sélectionné par l'attaquant.
Docmost prouvait l'accès en modification sur une page, acceptait un identifiant de pièce jointe existant depuis une autre page, puis laissait une vérification d'écrasement erronée transformer cette non‑correspondance en un remplacement réussi de fichier inter‑pages.
C'est pourquoi elle est devenue CVE-2026-34213.
Le correctif dans v0.71.0 a corrigé le problème immédiat proprement, mais la leçon plus large reste précieuse :
lorsque l'autorisation et la sélection d'objet sont réparties sur des champs séparés contrôlés par l'utilisateur, la liaison exacte est la propriété de sécurité.