Share
## https://sploitus.com/exploit?id=PACKETSTORM:224389
# CVE-2026-34212
    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.
    
    ## Intro
    
    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](https://github.com/docmost/docmost)  
    **Advisory:** [GHSA-cf68-cff9-hq4w](https://github.com/docmost/docmost/security/advisories/GHSA-cf68-cff9-hq4w)  
    **CVE:** CVE-2026-34212  
    **Patched in:** `v0.71.0`
    
    <img width="840" height="560" alt="photo0" src="https://github.com/user-attachments/assets/0f153c00-d467-40bd-8bd1-5560e568f1fa" />
    
    ---
    
    ## Attack Chain
    
    `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`
    
    ---
    
    ## What This Part of Docmost Does
    
    Docmost stores page content in a ProseMirror/Tiptap-compatible JSON format.
    
    That content model includes custom block nodes for things like:
    
    - images
    - diagrams
    - embeds
    - attachments
    
    The attachment node stores fields such as:
    
    - `url`
    - `name`
    - `mime`
    - `size`
    - `attachmentId`
    
    The server accepts page content in several formats:
    
    - `json`
    - `markdown`
    - `html`
    
    and 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.
    
    ---
    
    ## Why This Surface Was Worth Looking At
    
    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:
    
    - find every node type that stores a URL-like field
    - trace where that field is accepted
    - trace where that field is rendered
    - compare its sanitization behavior with the platform's normal link handling
    
    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.
    
    ---
    
    ## Root Cause
    
    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 | object`
    - `PageService.parseProsemirrorContent()` normalized `markdown`, `html`, or `json`
    - the server then called `jsonToNode(prosemirrorJson)`
    - if schema validation passed, the content was stored
    
    That validation step checked structural validity, not URL safety.
    
    The critical part of the vulnerable server logic was effectively:
    
    ```ts
    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:
    
    ```ts
    url: {
      default: "",
      parseHTML: (element) => element.getAttribute("data-attachment-url"),
      renderHTML: (attributes) => ({
        "data-attachment-url": attributes.url,
      }),
    },
    ```
    
    and then:
    
    ```ts
    [
      "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:
    
    ```tsx
    <a href={getFileUrl(url)} target="_blank">
    ```
    
    But `getFileUrl()` only special-cased:
    
    - absolute `http` URLs
    - `/api/...`
    - `/files/...`
    
    Anything else was returned unchanged.
    
    So a payload like:
    
    ```text
    javascript:alert(document.domain)
    ```
    
    survived:
    
    - JSON storage
    - server-side schema validation
    - HTML rendering
    - client-side URL handling
    
    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:`:
    
    - it rejected `javascript:` in `parseHTML()`
    - it blanked out a `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.
    
    ---
    
    ## Why This Is a Security Issue, Not Just Missing Sanitization
    
    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:
    
    - read data the victim can access
    - issue authenticated requests as the victim
    - modify content the victim is allowed to modify
    - abuse any DOM or API surface exposed to the session
    
    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.
    
    ---
    
    ## Why Exploitation Was Practical
    
    The exploit path was straightforward:
    
    - any user with page edit rights could plant the payload
    - the malicious URL survived storage unchanged
    - the page rendered normally
    - viewers only needed standard access to the page
    - one click on the attachment action was enough to trigger execution
    
    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.
    
    ---
    
    ## Proof of Concept
    
    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:
    
    1. Log in as a user who can edit a page.
    2. Create or select a page.
    3. Send `POST /api/pages/update` with `format: "json"` and an attachment node whose `url` is a `javascript:` payload.
    4. Request the page back through `POST /api/pages/info`.
    5. Confirm that the stored JSON still contains the malicious URL.
    6. Request the same page in HTML form and confirm that the server returns an anchor whose `href` is still `javascript:...`.
    7. In the UI, a viewer clicking the rendered attachment action executes the payload in the Docmost origin.
    
    The minimal malicious content was:
    
    ```json
    {
      "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"
    }
    ```
    
    The observed live result from my test was:
    
    - the API accepted the malicious attachment node unchanged
    - the stored page ID was `019d18cf-4212-70b0-894a-fe20080fb0f1`
    - `POST /api/pages/info` returned the stored JSON with:
    
    ```json
    "url": "javascript:alert(document.domain)"
    ```
    
    - `POST /api/pages/info` with `format: "html"` returned HTML containing:
    
    ```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>
    ```
    
    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.
    
    ---
    
    ## Why the PoC Was Chosen This Way
    
    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:
    
    1. **storage proof**
    2. **rendered sink proof**
    
    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:
    
    ```html
    <a href="javascript:...">
    ```
    
    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.
    
    ---
    
    ## Fix Analysis
    
    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:
    
    - sanitizing `data-attachment-url` during parsing
    - sanitizing `data-attachment-url` during rendering
    - sanitizing the anchor `href`
    
    Conceptually, the patch changed the attachment node from:
    
    - trust raw attachment URL
    - emit raw attachment URL
    
    to:
    
    - normalize attachment URL before it becomes part of the rendered node
    
    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:
    
    - the node rendered a raw `href`
    - the client fallback treated unknown schemes as acceptable
    
    The 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:
    
    - reject obviously dangerous schemes at ingestion
    - sanitize again at render boundaries
    
    Defense in depth matters in rich-content systems.
    
    ---
    
    ## Regression Cases That Matter
    
    For long-term coverage, these are the cases that matter most:
    
    - JSON page updates containing `attachment.attrs.url = "javascript:..."`
    - HTML imports containing `data-attachment-url="javascript:..."`
    - attachment rendering must never emit `href="javascript:..."`
    - client fallback helpers must not return unknown executable schemes unchanged
    - attachment nodes and normal link nodes should share equivalent URL-scheme policy
    - safe internal attachment paths such as `/api/files/...` and `/files/...` should continue to work normally
    
    The 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.
    
    ---
    
    ## Severity and Classification
    
    The published advisory classified this issue as:
    
    - **CWE-79**: Improper Neutralization of Input During Web Page Generation
    - **CVSS v3.1**:
    
    ```text
    CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N
    ```
    
    That lands at **7.6 / High**.
    
    This is a defensible classification.
    
    The important properties are:
    
    - low attacker privilege requirement
    - stored payload
    - execution in the Docmost origin
    - scope change
    - meaningful confidentiality impact because the script can access victim-visible in-app data
    
    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.
    
    ---
    
    ## Disclosure
    
    I reported the issue privately through GitHub Security Advisories with:
    
    - root-cause analysis
    - a live HTTP PoC
    - stored JSON evidence
    - rendered HTML sink evidence
    - a pinned disposable test lab
    
    The issue was accepted, assigned **CVE-2026-34212**, and published on **April 14, 2026**.
    
    The public advisory currently lists:
    
    - affected version: `0.70.3`
    - patched version: `0.71.0`
    
    My live validation was performed on `v0.70.3`, which matched the published vulnerable version.
    
    ---
    
    ## What This Bug Actually Teaches
    
    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:
    
    - the standard link path is hardened
    - the attachment path is treated as "internal" or "special"
    - the special path quietly becomes the easier XSS sink
    
    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:
    
    - is this content structurally valid?
    - is this content safe to store?
    - is this content safe to render at every sink?
    
    The attachment node passed the first question and failed the third.
    
    That is how stored content bugs survive inside otherwise well-structured editor pipelines.
    
    ---
    
    ## Key Points
    
    - Docmost accepted raw attachment node URLs in page content.
    - Server-side page validation checked ProseMirror schema shape, not URL-scheme safety.
    - The vulnerable attachment node rendered `data-attachment-url` and anchor `href` directly from attacker-controlled input.
    - The client helper `getFileUrl()` returned unknown schemes unchanged.
    - Normal link nodes already blocked `javascript:`, but attachment nodes did not.
    - A low-privileged editor could plant the payload once and target later viewers.
    - The live PoC proved both stored persistence and rendered executable sink.
    - The fix in `v0.71.0` added `sanitizeUrl` handling to the attachment node and client fallback path.
    
    ---
    
    ## Final Words
    
    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.