
Docmost принял javascript: URL внутри узла вложения, сохранил его при хранении и рендеринге и превратил обратно в кликабельную ссылку в источнике Docmost.
Docmost принимал URL с javascript: внутри узла вложения, сохранял его при хранении и рендеринге, и превращал обратно в кликабельную ссылку в источнике Docmost.
Я выявил, ответственно раскрыл и воспроизвел уязвимость хранимого XSS высокой степени серьезности в Docmost — платформе совместной документации с открытым исходным кодом.
Официальный сайт Docmost представляет её как корпоративную вики для развертывания на собственных серверах с 3 млн+ загрузок, и утверждает, что ей доверяют команды таких организаций, как Vilnius City, Bechtle, Правительство Австралии, Красный Крест и ETS Quebec.
Ошибка находилась в месте, которое легко упустить в системах с форматированным текстом:
не в обычном расширении для ссылок, а в отдельном пользовательском типе узла, используемом для вложений.
Я проверял конвейер редактора с очень конкретным вопросом:
если обычные ссылки блокируют URL с javascript:, применяют ли узлы вложений то же правило, прежде чем попасть в опасный приемник ссылки (anchor)?
В уязвимых версиях — нет.
Docmost принимал вредоносный узел вложения в JSON страницы, сохранял его атрибут url без изменений, а затем рендерил это значение обратно в кликабельный элемент <a href="javascript:...">.
Эта проблема стала CVE-2026-34212.
Docmost: docmost/docmost
Уведомление: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
Исправлено в: v0.71.0
URL узла вложения под контролем атакующего -> JSON страницы принимается и сохраняется без изменений -> рендеринг HTML/React превращает этот URL в href ссылки -> жертва нажимает на действие с вложением -> управляемый атакующим JavaScript выполняется в источнике Docmost
Docmost хранит содержимое страницы в формате JSON, совместимом с ProseMirror/Tiptap.
Эта модель содержимого включает пользовательские блочные узлы для таких вещей, как:
Узел вложения хранит такие поля, как:
urlnamemimesizeattachmentIdСервер принимает содержимое страницы в нескольких форматах:
jsonmarkdownhtmlи нормализует его в ProseMirror JSON перед сохранением.
Это означает, что любой тип узла, который может содержать URL, находится на прямой границе доверия.
Если один из этих типов узлов в конечном итоге рендерится в <a href>, обработка схемы URL не является опциональной.
Это часть модели безопасности.
Пользовательские расширения редактора часто являются источником дрейфа безопасности.
Базовая система, возможно, уже знает, как правильно обрабатывать опасные URL, но каждый пользовательский узел все равно должен повторно применять те же правила в своих собственных приемниках.
Это создает предсказуемую стратегию проверки:
Именно так и была обнаружена эта ошибка.
Обычное расширение ссылок Docmost уже считало javascript: опасным.
Узел вложения — нет.
Как только вы видите эту асимметрию, вопрос безопасности становится очевидным:
могу ли я сохранить узел вложения, чей url равен javascript:, и получить его обратно в виде живой ссылки?
Ответ был — да.
Первопричиной была непоследовательная очистка URL между типами узлов содержимого.
Серверный путь для содержимого принимал произвольные URL вложений, пока в целом содержимое соответствовало схеме ProseMirror.
В уязвимой версии:
CreatePageDto принимал content?: string | objectPageService.parseProsemirrorContent() нормализовал markdown, html или jsonjsonToNode(prosemirrorJson)Этот этап валидации проверял структурную корректность, а не безопасность URL.
Критическая часть уязвимой серверной логики фактически выглядела так:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
Никакой нормализации схемы URL вложений там не происходило.
Позже расширение вложения рендерило значение, контролируемое атакующим, напрямую.
Уязвимый узел вложения делал следующее:
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
а затем:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
На клиентской стороне представление узла React снова оборачивало это в:
<a href={getFileUrl(url)} target="_blank">
Но getFileUrl() обрабатывал только особые случаи:
http URL/api/.../files/...Всё остальное возвращалось без изменений.
Так что полезная нагрузка вида:
javascript:alert(document.domain)
выживала:
Уже этого было бы достаточно для хранимого XSS.
Что делает первопричину особенно ясной — это точка сравнения.
Обычное расширение ссылок Docmost явно блокировало javascript::
javascript: в parseHTML()javascript: в renderHTML()Таким образом, продукт уже знал, что эта схема опасна.
Узел вложения просто не применял ту же политику.
Вот почему это был не «общий XSS в редакторе».
Это был пробел на границе доверия, специфичный для узла.
Эта ошибка была не просто о небезопасной эстетике HTML.
Она позволяла атакующему, который мог редактировать страницу, сохранить вредоносную полезную нагрузку, которая позже выполнялась бы в источнике Docmost, когда другой пользователь взаимодействовал с отрендеренным вложением.
Это важно, потому что скрипт внутри источника может:
Требование клика не сводит это к тривиальной проблеме.
Клик является частью нормального поведения продукта: интерфейс намеренно представляет вложение как интерактивную ссылку/иконку.
Поэтому вопрос безопасности не в том, «может ли атакующий заставить произвольный JS без какого-либо взаимодействия?»
Реальный вопрос:
хранит ли приложение скриптоносное содержимое под контролем атакующего и затем представляет его другим пользователям как доверенный путь взаимодействия?
В уязвимых версиях — да.
Это хранимый XSS.
Путь эксплуатации был прямолинейным:
Это также делало пользователей с более высокими привилегиями реалистичными целями.
Если владелец рабочего пространства, администратор или широко доверенный редактор просматривал контент, контролируемый атакующим, и кликал по действию с вложением, скрипт атакующего запускался в контексте более привилегированной сессии.
Это важный практический момент:
требования к привилегиям атакующего были только низкими. Уровень привилегий жертвы определял ценность сессии XSS.