
Docmost a accepté une URL javascript: à l'intérieur d'un attachment node, l'a préservée lors du stockage et du rendu, et l'a retransformée en une ancre cliquable dans l'origine de Docmost.
Docmost a accepté une URL de type javascript: dans un nœud de pièce jointe, l’a conservée lors du stockage et du rendu, et l’a transformée en un lien cliquable dans l’origine de Docmost.
J’ai identifié, divulgué de manière responsable et reproduit un problème de stored XSS de sévérité élevée dans Docmost, la plateforme de documentation collaborative open-source.
Le site officiel de Docmost le présente comme un wiki interne prêt pour l’entreprise avec plus de 3 millions de téléchargements, et indique qu’il est utilisé par des équipes d’organisations telles que Vilnius City, Bechtle, le gouvernement australien, la Croix-Rouge et ETS Québec.
Le bogue se trouvait à un endroit facile à manquer dans les systèmes de texte enrichi :
pas dans l’extension de lien ordinaire, mais dans un type de nœud personnalisé distinct utilisé pour les pièces jointes.
Je passais en revue le pipeline de l’éditeur avec une question très précise en tête :
si les liens normaux bloquent les URLs javascript:, les nœuds de pièces jointes appliquent-ils la même règle avant d’atteindre un sink d’ancrage ?
Dans les versions vulnérables, ce n’était pas le cas.
Docmost acceptait un nœud de pièce jointe malveillant dans le JSON de la page, stockait son attribut url sans modification, puis rendait cette valeur sous la forme d’un élément cliquable.
<a href="javascript:...">Ce problème est devenu CVE-2026-34212.
Docmost : docmost/docmost
Avis : GHSA-cf68-cff9-hq4w
CVE : CVE-2026-34212
Corrigé dans : v0.71.0
URL du nœud de pièce jointe contrôlée par l’attaquant -> JSON de la page accepté et stocké sans modification -> rendu HTML/React transformant cette URL en href d’ancrage -> victime clique sur l’action de la pièce jointe -> JavaScript contrôlé par l’attaquant s’exécute dans l’origine de Docmost
Docmost stocke le contenu des pages dans un format JSON compatible avec ProseMirror/Tiptap.
Ce modèle de contenu inclut des nœuds de bloc personnalisés pour des éléments tels que :
Le nœud de pièce jointe stocke des champs comme :
urlnamemimesizeattachmentIdLe serveur accepte le contenu des pages dans plusieurs formats :
jsonmarkdownhtmlet le normalise en JSON ProseMirror avant de le stocker.
Cela signifie que tout type de nœud pouvant porter une URL fait partie d’une frontière de confiance directe.
Si l’un de ces types de nœuds finit par être rendu sous la forme d’un <a href>, la gestion des schémas d’URL n’est pas facultative.
Elle fait partie du modèle de sécurité.
Les extensions d’éditeur personnalisées sont une source fréquente de dérive de sécurité.
Le système de base peut déjà savoir comment traiter correctement les URLs dangereuses, mais chaque nœud personnalisé doit réappliquer les mêmes règles à ses propres sinks.
Cela crée une stratégie de révision prévisible :
C’est exactement ce qui a exposé ce bogue.
L’extension de lien normal de Docmost traitait déjà javascript: comme dangereux.
Son nœud de pièce jointe ne le faisait pas.
Une fois que vous voyez cette asymétrie, la question de sécurité devient évidente :
puis-je persister un nœud de pièce jointe dont l’url est javascript: et obtenir qu’il soit rendu sous forme d’ancrage actif ?
La réponse était oui.
La cause racine était un nettoyage d’URL incohérent entre les types de nœuds de contenu.
Le chemin de contenu côté serveur acceptait des URLs de pièces jointes arbitraires tant que le contenu global correspondait au schéma ProseMirror.
Dans la version vulnérable :
CreatePageDto acceptait content?: string | objectPageService.parseProsemirrorContent() normalisait markdown, html ou jsonjsonToNode(prosemirrorJson)Cette étape de validation vérifiait la validité structurelle, pas la sécurité de l’URL.
La partie critique de la logique serveur vulnérable était effectivement :
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
Aucune normalisation de schéma d’URL de pièce jointe n’avait lieu ici.
Plus tard, l’extension de pièce jointe rendait directement la valeur contrôlée par l’attaquant.
Le nœud de pièce jointe vulnérable faisait ceci :
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
puis :
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
Côté client, la vue de nœud React encapsulait cela à nouveau dans :
<a href={getFileUrl(url)} target="_blank">
Mais getFileUrl() ne traitait de manière spéciale que :
http absolues/api/.../files/...Tout le reste était renvoyé sans modification.
Ainsi, une charge utile comme :
javascript:alert(document.domain)
survivait à :
Cela seul serait déjà suffisant pour une XSS stockée.
Ce qui rend la cause racine particulièrement claire, c’est le point de comparaison.
L’extension de lien normal de Docmost bloquait explicitement javascript: :
javascript: dans parseHTML()javascript: dans renderHTML()Le produit savait donc déjà que ce schéma était dangereux.
Le nœud de pièce jointe n’a tout simplement pas appliqué la même politique.
Voilà pourquoi ce n’était pas une « XSS générique dans l’éditeur ».
C’était un écart de frontière de confiance propre à un nœud.
Ce bogue ne concernait pas simplement une esthétique HTML dangereuse.
Il permettait à un attaquant capable de modifier une page de persister une charge utile malveillante qui s’exécuterait plus tard dans l’origine de Docmost lorsqu’un autre utilisateur interagirait avec la pièce jointe rendue.
Cela compte car un script dans l’origine peut :
L’exigence d’un clic ne réduit pas cela à un problème trivial.
Le clic fait partie du comportement normal du produit : l’interface utilisateur présente intentionnellement la pièce jointe comme un lien/icône actionnable.
La question de sécurité n’est donc pas « l’attaquant peut-il forcer du JS arbitraire sans aucune interaction ? »
La vraie question est :
l’application stocke-t-elle du contenu porteur de script contrôlé par l’attaquant et le présente-t-elle ensuite à d’autres utilisateurs comme un chemin d’interaction de confiance ?
Dans les versions vulnérables, oui.
C’est une XSS stockée.
Le chemin d’exploitation était simple :
Cela rendait également les utilisateurs à privilèges élevés des cibles réalistes.
Si un propriétaire d’espace de travail, un administrateur ou un éditeur largement approuvé consultait un contenu contrôlé par l’attaquant et cliquait sur l’action de la pièce jointe, le script de l’attaquant s’exécutait dans ce contexte de session plus privilégié.
C’est le point pratique important :
l’exigence de privilège de l’attaquant était seulement faible. Le niveau de privilège de la victime déterminait la valeur de la session XSS.
J’ai validé le problème en direct contre Docmost v0.70.3.
Le PoC utilisait uniquement des requêtes HTTP normales et les API de pages de l’application.
Le déroulement était :
POST /api/pages/update avec format: "json" et un nœud de pièce jointe dont l’url est une charge utile javascript:.POST /api/pages/info.href est toujours javascript:....Le contenu malveillant minimal était :
{
"pageId": "<pageId>",
"content": {
"type": "doc",
"content": [
{
"type": "attachment",
"attrs": {
"url": "javascript:alert(document.domain)",
"name": "policy.pdf",
"mime": "application/pdf",
"size": 1
}
}
]
},
"operation": "replace",
"format": "json"
}
Le résultat en direct observé lors de mon test était :
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info renvoyait le JSON stocké avec :"url": "javascript:alert(document.domain)"
POST /api/pages/info avec format: "html" renvoyait du HTML contenant :<div data-type="attachment" data-attachment-url="javascript:alert(document.domain)" data-attachment-name="policy.pdf" data-attachment-mime="application/pdf" data-attachment-size="1"><a href="javascript:alert(document.domain)" class="attachment" target="blank">policy.pdf</a></div>
Cette réponse HTML est la preuve critique.
Je n’avais pas besoin de m’appuyer sur une affirmation vague selon laquelle « un navigateur pourrait faire quelque chose d’intéressant ».
L’application elle-même rendait le sink exécutable exact.
Lorsqu’un utilisateur clique sur ce lien/icône de pièce jointe, le navigateur exécute l’URL javascript: dans l’origine de la page qui l’a créée.
Pour une XSS basée sur l’éditeur, les captures d’écran seules sont des preuves faibles.
Elles montrent des symptômes, pas la défaillance de la frontière.
C’est pourquoi j’ai structuré le PoC autour de deux points de vérification explicites :
La preuve de stockage montrait que le serveur acceptait et préservait le schéma dangereux.
La preuve de sink rendu montrait que l’application transformait cette valeur stockée en :
<a href="javascript:...">
Cette distinction est importante.
Si un produit stocke une entrée dangereuse mais la neutralise avant chaque sink, il peut y avoir un écart de durcissement mais pas nécessairement une XSS active.
Si le produit stocke une entrée dangereuse et la rend ensuite dans un sink d’exécution réel, vous avez la chaîne complète de vulnérabilité.
C’est ce qui s’est passé ici.
Le correctif a été livré dans v0.71.0 et a traité le chemin d’exploitation rendu en appliquant un nettoyage d’URL aux URLs des pièces jointes.
L’extension de pièce jointe importe et utilise désormais sanitizeUrl, y compris :
data-attachment-url lors du parsingdata-attachment-url lors du renduhref de l’ancreConceptuellement, le patch a fait passer le nœud de pièce jointe de :
à :
L’assistant côté client getFileUrl() a également été mis à jour afin que les schémas inconnus ne passent plus sans modification.
Dans la version corrigée, le chemin de repli renvoie sanitizeUrl(src) au lieu de renvoyer src tel quel.
C’est une partie importante du correctif car la conception vulnérable avait deux problèmes qui se renforçaient mutuellement :
href brutLe patch a supprimé ces deux hypothèses.
C’était un bon correctif pour le chemin XSS actif car il a aligné le traitement des URLs de pièces jointes sur le reste du modèle de sécurité de l’éditeur.
Cela dit, il reste une leçon plus large de durcissement :
le nettoyage côté client ou au moment du rendu est nécessaire ici, mais le rejet côté serveur des schémas dangereux lors de la création/mise à jour de page serait une invariante encore plus forte.
Le modèle le plus sûr à long terme est :
La défense en profondeur est importante dans les systèmes de contenu enrichi.
Pour une couverture à long terme, voici les cas les plus importants :
attachment.attrs.url = "javascript:..."data-attachment-url="javascript:..."href="javascript:..."/api/files/... et /files/... doivent continuer à fonctionner normalementLe point clé est la cohérence.
Si les liens normaux sont nettoyés mais que les nœuds personnalisés porteurs d’URL ne le sont pas, l’éditeur n’a pas vraiment de politique de sécurité d’URL unique.
Il a des fragments, et les fragments sont l’endroit où vivent les bogues XSS.
L’avis publié a classé ce problème comme :
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Cela donne un score de 7.6 / Élevé.
Il s’agit d’une classification défendable.
Les propriétés importantes sont :
L’interaction de l’utilisateur reste requise car la victime doit activer le lien/icône de la pièce jointe.
C’est pourquoi UI:R est correct.
Mais une fois cette interaction réalisée, la frontière de sécurité a déjà échoué bien plus tôt : l’application a stocké un schéma dangereux et l’a rendu dans un sink d’exécution.
J’ai signalé le problème de manière privée via GitHub Security Advisories avec :
Le problème a été accepté, s’est vu attribuer CVE-2026-34212, et a été publié le 14 avril 2026.
L’avis public liste actuellement :
0.70.30.71.0Ma validation en direct a été effectuée sur v0.70.3, qui correspondait à la version vulnérable publiée.
La leçon principale ici n’est pas simplement « nettoyez les URLs ».
Tout le monde le sait déjà.
La leçon plus intéressante est :
si une application a un type de nœud porteur d’URL sécurisé et un type de nœud porteur d’URL non sécurisé, c’est le non sécurisé qui constitue la véritable politique.
Les systèmes de texte enrichi accumulent souvent des extensions personnalisées plus vite qu’ils n’accumulent de révisions de sécurité.
Cela crée exactement ce type d’asymétrie :
Ce bogue montre également pourquoi la validation de schéma ne suffit pas.
jsonToNode() vérifiait que le contenu était structurellement valide selon ProseMirror.
Cela ne prouvait pas que le contenu était sûr à rendre.
Ce sont des questions différentes.
La révision de sécurité devient beaucoup plus précise lorsque vous gardez ces questions séparées :
Le nœud de pièce jointe a réussi la première question et échoué à la troisième.
C’est ainsi que les bogues de contenu stocké survivent à l’intérieur de pipelines d’éditeurs par ailleurs bien structurés.
data-attachment-url et le href de l’ancre directement à partir de l’entrée contrôlée par l’attaquant.getFileUrl() renvoyait les schémas inconnus inchangés.javascript:, mais les nœuds de pièces jointes ne le faisaient pas.v0.71.0 a ajouté la gestion sanitizeUrl au nœud de pièce jointe et au chemin de repli côté client.Cette vulnérabilité ne concernait pas une bizarrerie de navigateur.
Elle concernait un nœud de contenu personnalisé qui contournait les propres hypothèses de sécurité d’URL de l’application.
Docmost a accepté une URL de pièce jointe contrôlée par l’attaquant, l’a conservée lors du stockage, puis l’a rendue sous forme d’ancre active dans l’origine de l’application.
C’est pourquoi elle est devenue CVE-2026-34212.
Le correctif dans v0.71.0 a fermé proprement le chemin XSS actif, mais la leçon plus large est celle à retenir :
dans les applications centrées sur l’éditeur, chaque nœud personnalisé pouvant porter une URL constitue sa propre frontière de sécurité, et il doit être examiné comme tel.