
Docmost は、アタッチメントノード内の javascript: URL を受け入れ、それをストレージとレンダリングを通じて保持し、Docmost オリジン内のクリック可能なアンカーに変換していました。
私は Docmost(オープンソースの共同作業ドキュメントプラットフォーム)において、高深刻度の保存型XSS の問題を特定し、責任を持って開示し、再現しました。
Docmost の公式サイトでは、エンタープライズ対応のオンプレミス Wiki として 300 万以上のダウンロード があり、ビリニュス市、Bechtle、オーストラリア政府、赤十字社、ETS Quebec などの組織のチームから信頼されていると謳っています。
このバグは、リッチテキストシステムで見落とされがちな場所にありました。
通常のリンク拡張ではなく、ファイルアタッチメントに使用される別のカスタムノードタイプ内です。
私は、非常に具体的な疑問を念頭に置いてエディタパイプラインをレビューしていました。
通常のリンクが javascript: URL をブロックする場合、アタッチメントノードはアンカーシンクに到達する前に同じルールを適用するのか?
脆弱なバージョンでは、適用していませんでした。
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 はページコンテンツを ProseMirror/Tiptap 互換の JSON 形式で保存します。
そのコンテンツモデルには、以下のようなカスタムブロックノードが含まれています:
アタッチメントノードは次のようなフィールドを保存します:
urlnamemimesizeattachmentIdサーバーはページコンテンツをいくつかの形式で受け入れます:
jsonmarkdownhtmlそして、保存する前にそれらを ProseMirror JSON に正規化します。
つまり、URL を持つ可能性のあるノードタイプはすべて、直接の信頼境界の一部です。
それらのノードタイプのいずれかが最終的に <a href> にレンダリングされる場合、URL スキームの処理はオプションではありません。
それはセキュリティモデルの一部です。
カスタムエディタ拡張は、セキュリティのずれが頻繁に発生する原因です。
ベースシステムはすでに危険なURLを正しく処理する方法を知っているかもしれませんが、各カスタムノードは独自のシンクで同じルールを再適用する必要があります。
これにより、予測可能なレビュー戦略が生まれます:
これこそが、このバグを露呈させたものです。
Docmost の通常のリンク拡張はすでに javascript: を危険として扱っていました。
そのアタッチメントノードはそうではありませんでした。
この非対称性を見れば、セキュリティ上の疑問は明らかです:
url が javascript: であるアタッチメントノードを永続化し、それをライブアンカーにレンダリングさせることができるか?
答えは「はい」でした。
根本原因は コンテンツノードタイプ間での URL サニタイズの不整合 でした。
サーバー側のコンテンツパスは、全体的なコンテンツが ProseMirror スキーマに一致する限り、任意のアタッチメント URL を受け入れました。
脆弱なバージョンでは:
CreatePageDto は content?: string | object を受け入れましたPageService.parseProsemirrorContent() は markdown、html、または json を正規化しましたjsonToNode(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: を明示的にブロックしていました:
parseHTML() で javascript: を拒否renderHTML() で javascript: href を空白に設定したがって、製品はこのスキームが危険であることをすでに認識していました。
アタッチメントノードは単に同じポリシーを適用できなかっただけです。
これが「エディタの汎用 XSS」ではなかった理由です。
これはノード固有の信頼境界のギャップでした。
このバグは、安全でない HTML の美学に関するものだけではありません。
ページを編集できる攻撃者が、他のユーザーがレンダリングされたアタッチメントを操作したときに後に Docmost オリジンで実行される悪意のあるペイロードを永続化できることを意味していました。
これは、オリジン内のスクリプトが以下のことを実行できるため重要です:
クリックが必要であることは、この問題を軽微なものにしません。
クリックは通常の製品動作の一部です: UI は意図的にアタッチメントをアクション可能なリンク/アイコンとして提示します。
したがって、セキュリティ上の質問は「攻撃者は何の操作もなしに任意の JS を強制できるか?」ではありません。
本当の質問は:
アプリケーションは、攻撃者が制御するスクリプトを含むコンテンツを保存し、後で信頼された操作パスとして他のユーザーに提示するか?
脆弱なバージョンでは、そうしていました。
それが保存型 XSS です。
悪用パスは単純でした:
これにより、高権限ユーザーも現実的な標的になりました。
ワークスペース所有者、管理者、または広く信頼されているエディタが攻撃者が制御するコンテンツを表示し、アタッチメントアクションをクリックすると、攻撃者のスクリプトはそのより権限の高いセッションコンテキストで実行されます。
これが重要な実際的なポイントです:
攻撃者の必要な権限は 低 だけでした。 被害者の権限レベルが、XSS セッションがどれだけの価値を持つかを決定しました。
私は Docmost v0.70.3 に対してこの問題をライブで検証しました。
PoC は通常の HTTP リクエストとアプリケーション独自のページ API のみを使用しました。
フローは次のとおりです:
format: "json" と、url が javascript: ペイロードであるアタッチメントノードを含む POST /api/pages/update を送信します。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-fe20080fb0f1 でしたPOST /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 応答が重要な証拠です。
「ブラウザが何か面白いことをするかもしれない」という曖昧な主張に頼る必要はありませんでした。
アプリケーション自体が正確な実行可能シンクをレンダリングしました。
ユーザーがそのアタッチメントリンク/アイコンをクリックすると、ブラウザは javascript: URL をそのページを作成したオリジンで実行します。
エディタ駆動の XSS の場合、スクリーンショットだけでは弱い証拠です。
症状は示しますが、境界の失敗は示しません。
そのため、PoC を2つの明示的なチェックポイントで構成しました:
ストレージの証明は、サーバーが危険なスキームを受け入れ、保持したことを示しました。
レンダリングされたシンクの証明は、アプリケーションがその保存された値を次のように戻したことを示しました:
<a href="javascript:...">
この分割が重要です。
製品が危険な入力を保存しても、すべてのシンクの前にそれを無効化する場合、強化のギャップはあるかもしれませんが、必ずしもライブの XSS があるわけではありません。
製品が危険な入力を保存し、後にそれを実際の実行シンクにレンダリングする場合、完全な脆弱性チェーンがあります。
これがここで起こったことです。
修正は v0.71.0 で提供され、アタッチメント URL に URL サニタイズを適用することで、レンダリングされた悪用パスに対処しました。
アタッチメント拡張は現在 sanitizeUrl をインポートして使用しており、以下を含みます:
data-attachment-url のサニタイズdata-attachment-url のサニタイズhref のサニタイズ概念的には、パッチはアタッチメントノードを次のように変更しました:
から:
クライアント側のヘルパー getFileUrl() も更新され、未知のスキームがそのまま通過しなくなりました。
パッチ適用バージョンでは、フォールバックパスは sanitizeUrl(src) を返し、src をそのまま返すのではなくなります。
これは修正の重要な部分です。なぜなら、脆弱な設計には2つの補強的な問題があったからです:
href をレンダリングしたパッチは両方の前提を削除しました。
これはライブの XSS パスに対する良い修正でした。なぜなら、アタッチメント URL 処理をエディタの他のセキュリティモデルと再調整したからです。
とはいえ、より広範な強化の教訓があります:
ここではクライアント側またはレンダリング時のサニタイズが必要ですが、ページ作成/更新時に危険なスキームをサーバー側で拒否することは、さらに強力な不変条件になります。
最も安全な長期モデルは次のとおりです:
リッチコンテンツシステムでは多層防御が重要です。
長期的なカバレッジのために、最も重要なケースは次のとおりです:
attachment.attrs.url = "javascript:..." を含む JSON ページ更新data-attachment-url="javascript:..." を含む HTML インポート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 / High になります。
これは妥当な分類です。
重要な特性は次のとおりです:
ユーザーの相互作用は依然として必要です。被害者はアタッチメントリンク/アイコンをアクティブにする必要があるためです。
そのため UI:R は正しいです。
しかし、その相互作用が発生すると、セキュリティ境界ははるか以前にすでに失敗しています: アプリケーションは危険なスキームを保存し、それを実行シンクにレンダリングしました。
私は GitHub Security Advisories を通じて非公開で問題を報告しました:
問題は受理され、CVE-2026-34212 が割り当てられ、2026年4月14日 に公開されました。
公開アドバイザリには現在:
0.70.30.71.0私のライブ検証は v0.70.3 で実行され、公開された脆弱なバージョンと一致しました。
主な教訓は単に「URL をサニタイズする」ではありません。
誰もがすでにそれを知っています。
より興味深い教訓は次のとおりです:
アプリケーションに安全な URL 保持ノードタイプと安全でない URL 保持ノードタイプがある場合、安全でない方が実際のポリシーです。
リッチテキストシステムは、セキュリティレビューよりも速くカスタム拡張を蓄積することがよくあります。
これにより、まさにこの種の非対称性が生まれます:
このバグはまた、スキーマ検証だけでは不十分である理由を示しています。
jsonToNode() はコンテンツが構造的に有効な ProseMirror データであることを検証しました。
コンテンツがレンダリングしても安全であることを証明しませんでした。
これらは異なる質問です。
セキュリティレビューは、これらの質問を分けて考えるとはるかに鋭くなります:
アタッチメントノードは最初の質問を通過し、3番目の質問に失敗しました。
それが、保存されたコンテンツのバグが、他の点では適切に構造化されたエディタパイプライン内で生き残る方法です。
data-attachment-url とアンカー href を攻撃者が制御する入力から直接レンダリングしました。getFileUrl() は、未知のスキームを変更せずに返しました。javascript: をブロックしていましたが、アタッチメントノードはブロックしませんでした。v0.71.0 の修正では、アタッチメントノードとクライアントフォールバックパスに sanitizeUrl 処理を追加しました。この脆弱性はブラウザの癖に関するものではありません。
アプリケーション自身の URL 安全仮定をバイパスするカスタムコンテンツノードに関するものでした。
Docmost は攻撃者が制御するアタッチメント URL を受け入れ、ストレージを通じて保持し、その後アプリケーションオリジン内のライブアンカーにレンダリングしました。
それが CVE-2026-34212 になった理由です。
v0.71.0 のパッチはアクティブな XSS パスをきれいに閉じましたが、より広範な教訓は保持する価値があります:
エディタが多用されるアプリケーションでは、URL を保持できるカスタムノードはそれぞれ独自のセキュリティ境界であり、そのようにレビューされる必要があります。