
Docmost accepted a javascript: URL inside an attachment node, preserved it through storage and rendering, and turned it back into a clickable anchor in the Docmost origin.
Docmost accepted a javascript: URL inside an attachment node, preserved it through storage and rendering, and turned it back into a clickable anchor in the Docmost origin.
I identified, responsibly disclosed, and reproduced a High-severity stored XSS issue in Docmost, the open-source collaborative documentation platform.
Docmost’s official site presents it as an enterprise-ready on-premises wiki with 3M+ downloads, and says it is trusted by teams at organizations including Vilnius City, Bechtle, the Australian Government, the Red Cross, and ETS Quebec.
The bug sat in a place that is easy to miss in rich-text systems:
not in the ordinary link extension, but in a separate custom node type used for file attachments.
I was reviewing the editor pipeline with a very specific question in mind:
if normal links block javascript: URLs, do attachment nodes enforce the same rule before they reach an anchor sink?
In vulnerable versions, they did not.
Docmost accepted a malicious attachment node in page JSON, stored its url attribute unchanged, and later rendered that value back into a clickable <a href="javascript:..."> element.
That issue became CVE-2026-34212.
Docmost: docmost/docmost
Advisory: GHSA-cf68-cff9-hq4w
CVE: CVE-2026-34212
Patched in: v0.71.0
attacker-controlled attachment node URL -> page JSON accepted and stored unchanged -> HTML/React rendering turns that URL into anchor href -> victim clicks attachment action -> attacker-controlled JavaScript executes in the Docmost origin
Docmost stores page content in a ProseMirror/Tiptap-compatible JSON format.
That content model includes custom block nodes for things like:
The attachment node stores fields such as:
urlnamemimesizeattachmentIdThe server accepts page content in several formats:
jsonmarkdownhtmland normalizes it into ProseMirror JSON before storing it.
That means any node type that can carry a URL is part of a direct trust boundary.
If one of those node types eventually renders into an <a href>, URL scheme handling is not optional.
It is part of the security model.
Custom editor extensions are a frequent source of security drift.
The base system may already know how to handle dangerous URLs correctly, but each custom node still has to reapply the same rules at its own sinks.
That creates a predictable review strategy:
That is exactly what exposed this bug.
Docmost's normal link extension already treated javascript: as dangerous.
Its attachment node did not.
Once you see that asymmetry, the security question becomes obvious:
can I persist an attachment node whose url is javascript: and get it rendered back into a live anchor?
The answer was yes.
The root cause was inconsistent URL sanitization across content node types.
The server-side content path accepted arbitrary attachment URLs as long as the overall content matched the ProseMirror schema.
In the vulnerable version:
CreatePageDto accepted content?: string | objectPageService.parseProsemirrorContent() normalized markdown, html, or jsonjsonToNode(prosemirrorJson)That validation step checked structural validity, not URL safety.
The critical part of the vulnerable server logic was effectively:
prosemirrorJson = content;
jsonToNode(prosemirrorJson);
return prosemirrorJson;
No attachment URL scheme normalization happened there.
Later, the attachment extension rendered the attacker-controlled value directly.
The vulnerable attachment node did this:
url: {
default: "",
parseHTML: (element) => element.getAttribute("data-attachment-url"),
renderHTML: (attributes) => ({
"data-attachment-url": attributes.url,
}),
},
and then:
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
]
On the client side, the React node view wrapped that again in:
<a href={getFileUrl(url)} target="_blank">
But getFileUrl() only special-cased:
http URLs/api/.../files/...Anything else was returned unchanged.
So a payload like:
javascript:alert(document.domain)
survived:
That alone would already be enough for stored XSS.
What makes the root cause especially clear is the comparison point.
Docmost's normal link extension explicitly blocked javascript::
javascript: in parseHTML()javascript: href in renderHTML()So the product already knew this scheme was dangerous.
The attachment node simply failed to apply the same policy.
That is why this was not "generic XSS in the editor."
It was a node-specific trust-boundary gap.
This bug was not merely about unsafe HTML aesthetics.
It allowed an attacker who could edit a page to persist a malicious payload that would later execute in the Docmost origin when another user interacted with the rendered attachment.
That matters because in-origin script can:
The requirement for a click does not reduce this to a trivial issue.
The click is part of the normal product behavior: the UI intentionally presents the attachment as an actionable link/icon.
So the security question is not "can the attacker force arbitrary JS without any interaction?"
The real question is:
does the application store attacker-controlled script-bearing content and later present it back to other users as a trusted interaction path?
In vulnerable versions, it did.
That is stored XSS.
The exploit path was straightforward:
This also made higher-privileged users realistic targets.
If a workspace owner, admin, or broadly trusted editor viewed attacker-controlled content and clicked the attachment action, the attacker's script would run in that more privileged session context.
That is the important practical point:
the attacker's privilege requirement was only low. The victim's privilege level determined how much value the XSS session carried.
I validated the issue live against Docmost v0.70.3.
The PoC used only normal HTTP requests and the application's own page APIs.
The flow was: