
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.
Я подтвердил проблему вживую на Docmost v0.70.3.
PoC использовал только обычные HTTP-запросы и собственные API страниц приложения.
Последовательность была:
POST /api/pages/update с format: "json" и узлом вложения, чей url — полезная нагрузка с javascript:.POST /api/pages/info.href все еще javascript:....Минимальное вредоносное содержимое было:
{
"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"
}
Наблюдаемый живой результат моего теста:
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info вернул сохраненный JSON с:"url": "javascript:alert(document.domain)"
POST /api/pages/info с format: "html" вернул HTML, содержащий:<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>
Этот HTML-ответ является критическим доказательством.
Мне не нужно было опираться на расплывчатое утверждение, что «браузер может сделать что-то интересное».
Приложение само отрендерило точный исполняемый приемник.
Как только пользователь кликает по ссылке/иконке вложения, браузер выполняет URL javascript: в источнике страницы, которая его создала.
Для XSS, управляемого редактором, одних скриншотов недостаточно.
Они показывают симптомы, а не сбой на границе.
Вот почему я структурировал PoC вокруг двух явных контрольных точек:
Доказательство хранения показало, что сервер принял и сохранил опасную схему.
Доказательство отрендеренного приемника показало, что приложение превратило это сохраненное значение обратно в:
<a href="javascript:...">
Это разделение имеет значение.
Если продукт сохраняет опасный ввод, но нейтрализует его перед каждым приемником, у вас может быть пробел в усилении защиты, но не обязательно живой XSS.
Если продукт сохраняет опасный ввод и затем рендерит его в реальный исполняемый приемник, у вас есть полная цепочка уязвимости.
Именно это и произошло здесь.
Исправление было выпущено в v0.71.0 и устранило путь эксплуатации путем применения очистки URL к URL вложений.
Расширение вложения теперь импортирует и использует sanitizeUrl, включая:
data-attachment-url во время разбораdata-attachment-url во время рендерингаКонцептуально, патч изменил узел вложения с:
на:
Клиентский хелпер getFileUrl() также был обновлен, так что неизвестные схемы больше не проходят без изменений.
В исправленной версии путь по умолчанию возвращает sanitizeUrl(src) вместо возврата src как есть.
Это важная часть исправления, потому что уязвимый дизайн имел две усиливающие проблемы:
hrefПатч удалил оба предположения.
Это было хорошее исправление для живого пути XSS, потому что оно привело обработку URL вложений в соответствие с остальной моделью безопасности редактора.
Тем не менее, есть еще более широкий урок по усилению защиты:
очистка на клиенте или во время рендеринга здесь необходима, но серверное отклонение опасных схем во время создания/обновления страницы было бы еще более сильным инвариантом.
Самая безопасная долгосрочная модель:
Глубокоэшелонированная защита имеет значение в системах с форматированным содержимым.
Для долгосрочного покрытия наиболее важны следующие случаи:
attachment.attrs.url = "javascript:..."data-attachment-url="javascript:..."href="javascript:..."/api/files/... и /files/..., должны продолжать работать нормальноКлючевой момент — согласованность.
Если обычные ссылки очищаются, а пользовательские узлы, несущие URL, — нет, то у редактора на самом деле нет единой политики безопасности URL.
У него есть фрагменты, а фрагменты — это то место, где живут ошибки XSS.
Опубликованное уведомление классифицировало эту проблему как:
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
Это дает 7.6 / Высокий.
Это обоснованная классификация.
Важные свойства:
Взаимодействие с пользователем по-прежнему требуется, потому что жертва должна активировать ссылку/иконку вложения.
Вот почему UI:R правилен.
Но как только это взаимодействие происходит, граница безопасности уже была нарушена гораздо раньше: приложение сохранило опасную схему и отрендерило её обратно в исполняемый приемник.
Я сообщил о проблеме приватно через GitHub Security Advisories с:
Проблема была принята, ей присвоен CVE-2026-34212, и она была опубликована 14 апреля 2026 года.
Публичное уведомление в настоящее время указывает:
0.70.30.71.0Мое живое подтверждение было выполнено на v0.70.3, что совпадает с опубликованной уязвимой версией.
Главный урок здесь не просто «очищать URL».
Это и так все знают.
Более интересный урок:
Если в приложении есть один безопасный тип узла, несущего URL, и один небезопасный, то небезопасный и есть реальная политика.
Системы с форматированным текстом часто накапливают пользовательские расширения быстрее, чем накапливают проверку безопасности.
Это создает именно такую асимметрию:
Эта ошибка также показывает, почему валидации схемы недостаточно.
jsonToNode() проверял, что содержимое структурно является корректными данными ProseMirror.
Он не доказывал, что содержимое безопасно для рендеринга.
Это разные вопросы.
Проверка безопасности становится намного острее, если разделять эти вопросы:
Узел вложения прошел первый вопрос и провалил третий.
Вот как ошибки с сохраненным содержимым выживают внутри в остальном хорошо структурированных конвейеров редакторов.
data-attachment-url и href ссылки напрямую из ввода, контролируемого атакующим.getFileUrl() возвращал неизвестные схемы без изменений.javascript:, а узлы вложений — нет.v0.71.0 добавило обработку sanitizeUrl для узла вложений и клиентского пути fallback.Эта уязвимость была не о странности браузера.
Она была о пользовательском узле содержимого, который обошел собственные предположения безопасности URL приложения.
Docmost принял URL вложения, контролируемый атакующим, сохранил его, а затем отрендерил обратно в живую ссылку в источнике приложения.
Вот почему она стала CVE-2026-34212.
Патч в v0.71.0 чисто закрыл активный путь XSS, но более широкий урок стоит запомнить:
в приложениях с тяжелым редактором каждый пользовательский узел, который может нести URL, является собственной границей безопасности, и его нужно проверять как таковой.