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:
No attachment URL scheme normalization happened there.
Later, the attachment extension rendered the attacker-controlled value directly.
The vulnerable attachment node did this:
and then:
On the client side, the React node view wrapped that again in:
But getFileUrl() only special-cased:
http URLs/api/.../files/...Anything else was returned unchanged.
So a payload like:
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:
POST /api/pages/update with format: "json" and an attachment node whose url is a javascript: payload.POST /api/pages/info.href is still javascript:....The minimal malicious content was:
The observed live result from my test was:
019d18cf-4212-70b0-894a-fe20080fb0f1POST /api/pages/info returned the stored JSON with:POST /api/pages/info with format: "html" returned HTML containing:That HTML response is the critical proof.
I did not need to rely on a hand-wavy claim that "a browser might do something interesting."
The application itself rendered the exact executable sink.
Once a user clicks that attachment link/icon, the browser executes the javascript: URL in the origin of the page that created it.
For editor-driven XSS, screenshots alone are weak evidence.
They show symptoms, not the boundary failure.
That is why I structured the PoC around two explicit checkpoints:
The storage proof showed that the server accepted and preserved the dangerous scheme.
The rendered sink proof showed that the application turned that stored value back into:
That split matters.
If a product stores dangerous input but neutralizes it before every sink, you may have a hardening gap but not necessarily a live XSS.
If the product stores dangerous input and later renders it into a real execution sink, you have the full vulnerability chain.
That is what happened here.
The fix shipped in v0.71.0 and addressed the rendered exploit path by applying URL sanitization to attachment URLs.
The attachment extension now imports and uses sanitizeUrl, including:
data-attachment-url during parsingdata-attachment-url during renderinghrefConceptually, the patch changed the attachment node from:
to:
The client-side helper getFileUrl() was also updated so that unknown schemes no longer pass through untouched.
In the patched version, the fallback path returns sanitizeUrl(src) instead of returning src as-is.
That is an important part of the fix because the vulnerable design had two reinforcing problems:
hrefThe patch removed both assumptions.
This was a good fix for the live XSS path because it brought attachment URL handling back into alignment with the rest of the editor's security model.
That said, there is still a broader hardening lesson:
client-side or render-time sanitization is necessary here, but server-side rejection of dangerous schemes during page create/update would be an even stronger invariant.
The safest long-term model is:
Defense in depth matters in rich-content systems.
For long-term coverage, these are the cases that matter most:
attachment.attrs.url = "javascript:..."data-attachment-url="javascript:..."href="javascript:..."/api/files/... and /files/... should continue to work normallyThe key point is consistency.
If normal links are sanitized but custom URL-bearing nodes are not, the editor does not really have a single URL security policy.
It has fragments, and fragments are where XSS bugs live.
The published advisory classified this issue as:
That lands at 7.6 / High.
This is a defensible classification.
The important properties are:
User interaction remains required because the victim has to activate the attachment link/icon.
That is why UI:R is correct.
But once that interaction happens, the security boundary has already failed much earlier: the application stored a dangerous scheme and rendered it back into an execution sink.
I reported the issue privately through GitHub Security Advisories with:
The issue was accepted, assigned CVE-2026-34212, and published on April 14, 2026.
The public advisory currently lists:
0.70.30.71.0My live validation was performed on v0.70.3, which matched the published vulnerable version.
The main lesson here is not simply "sanitize URLs."
Everybody already knows that.
The more interesting lesson is:
if an application has one safe URL-bearing node type and one unsafe URL-bearing node type, the unsafe one is the real policy.
Rich-text systems often accumulate custom extensions faster than they accumulate security review.
That creates exactly this kind of asymmetry:
This bug also shows why schema validation is not enough.
jsonToNode() verified that the content was structurally valid ProseMirror data.
It did not prove that the content was safe to render.
Those are different questions.
Security review gets much sharper when you keep those questions separate:
The attachment node passed the first question and failed the third.
That is how stored content bugs survive inside otherwise well-structured editor pipelines.
data-attachment-url and anchor href directly from attacker-controlled input.getFileUrl() returned unknown schemes unchanged.javascript:, but attachment nodes did not.v0.71.0 added sanitizeUrl handling to the attachment node and client fallback path.This vulnerability was not about a browser quirk.
It was about a custom content node that bypassed the application's own URL safety assumptions.
Docmost accepted an attacker-controlled attachment URL, preserved it through storage, and then rendered it back into a live anchor in the application origin.
That is why it became CVE-2026-34212.
The patch in v0.71.0 closed the active XSS path cleanly, but the broader lesson is the one worth keeping:
in editor-heavy applications, every custom node that can carry a URL is its own security boundary, and it has to be reviewed like one.