Skip to content

XSS-to-RCE via malicious filename in the upload/drag-drop validation flow (app/src/protyle/upload/index.ts), reflected unescaped into showMessage's insertAdjacentHTML sink

High
88250 published GHSA-jf56-jrhq-j2qp Aug 4, 2026

Package

gomod siyuan (Go)

Affected versions

3.7.3

Patched versions

v3.7.4

Description

Same shared sink and same Electron root cause as the vendor's published
siyuan:// deep-link chain (GHSA-6gx2-8gcr-x83f and its 7 prior
siblings: GHSA-pcjq-j3mq-jv5j, GHSA-27qc-m5gf-jv5r, GHSA-mvjr-vv3c-w4qv,
GHSA-ff66-236v-p4fg, GHSA-phhp-9rm9-6gr2, GHSA-grjj-6f6g-cq8q,
GHSA-2h64-c999-c9r6), but a completely different trigger path: dragging
or pasting a maliciously-named file into the editor, not clicking a
crafted link. Confirmed via source reading that this specific call site
was never patched, unlike the two sibling showMessage call sites in
config/index.ts and protyle/export/util.ts, which do call
escapeHtml().

Summary

When a file is dragged, dropped, or pasted into a SiYuan document, the
upload validation code builds status/error HTML strings by directly
concatenating the file's own name, with no escaping, and passes the
result to showMessage(), which inserts it via
element.insertAdjacentHTML(...) with no sanitization at that layer
either. A file with a crafted name containing an HTML/script payload
executes that payload the moment the user drags it into the editor, no
click, no confirmation, no link required. Every BrowserWindow in the
SiYuan desktop app runs with nodeIntegration:true,
contextIsolation:false, webSecurity:false, and no CSP (the same
configuration the vendor's own published deep-link advisories rely on),
so the injected script has immediate access to
window.require("child_process") and full OS-level code execution at
the user's privileges. This is CWE-79 (Cross-Site Scripting) escalating
to CWE-94 (Code Injection) / arbitrary OS command execution via the
Electron configuration.

Details

app/src/protyle/upload/index.ts, validateFile() (around line 33):

const validateFile = (protyle: IProtyle, files: File[]) => {
    const uploadFileList = [];
    let errorTip = "";
    let uploadingStr = "";

    for (let iMax = files.length, i = 0; i < iMax; i++) {
        const file = files[i];
        ...
        if (file.size > protyle.options.upload.max) {
            errorTip += `<li>${file.name} ${window.siyuan.languages.over} ${protyle.options.upload.max / 1024 / 1024}M</li>`;
            validate = false;
        }
        ...
        if (protyle.options.upload.accept) {
            ...
            if (!isAccept) {
                errorTip += `<li>${file.name} ${window.siyuan.languages.fileTypeError}</li>`;
                validate = false;
            }
        }

        if (validate) {
            uploadFileList.push(file);
            uploadingStr += `<li>${filename} ${window.siyuan.languages.uploading}</li>`;
        }
    }
    let msgId;
    if (errorTip !== "" || uploadingStr !== "") {
        msgId = showMessage(`<ul>${errorTip}${uploadingStr}</ul>`, -1);
    }
    ...
};

file.name is the browser/OS-supplied filename of whatever file the
user drags, drops, or pastes, entirely attacker-controlled if the
attacker can get the victim to drag in a file they crafted the name of
(sent via chat, email, a shared folder, a USB drive, or downloaded from
a web page). It is concatenated directly into an HTML string with no
call to escapeHtml() or any equivalent, at up to three separate
points in this function (the two errorTip branches and the
uploadingStr branch, the latter using the lightly-transformed
filename variable, which only strips/replaces the extension via
protyle.options.upload.filename(...), not HTML-escapes it).

showMessage() (app/src/dialog/message.ts, confirmed by direct
reading) inserts its message argument via .insertAdjacentHTML(...)
with no escaping of its own:

export const showMessage = (message: string, timeout = 6000, type = "info", messageId?: string) => {
    ...
    let messageHTML = `<div data-id="${id}" class="...">...${messageVersion}</div>...`;
    ...
    messagesElement.insertAdjacentHTML("afterbegin", messageHTML + "</div>");
    ...
};

where messageVersion is derived directly from the caller-supplied
message.

For contrast, two other showMessage call sites that also interpolate
external strings were fixed at the sink by wrapping the value in
escapeHtml():

// app/src/config/index.ts:132 (fix for the published siyuan:// deep-link chain)
showMessage(`Package not found: ${escapeHtml(itemName)}`);

// app/src/protyle/export/util.ts:20
showMessage(`${window.siyuan.languages.exported} ${escapeHtml(exportPath)}...`);

validateFile()'s three interpolations of file.name/filename were
not part of either fix and remain unescaped, confirming this is the
same unfixed-sink pattern the vendor's own advisories describe
("each fixed at the sink, never at the profile... roughly 20 reflection
paths across the codebase, so a per-call escape leaves siblings live").

Step-by-step reproduction

  1. On any OS, create a file whose name contains an HTML/script payload,
    for example:
    <img src=x onerror="require('child_process').exec('calc')">.png
    
    (exact special-character handling may need minor adjustment per OS
    filename restrictions, e.g. substituting characters the local
    filesystem disallows; the core payload shape is unaffected)
  2. Open the SiYuan desktop app to any document in the editor.
  3. Drag the crafted file from the file manager directly into the open
    document (or paste it via clipboard, or drop it onto the editor
    area), triggering the normal "insert attachment/image" upload flow.
  4. If the file exceeds the configured upload size limit, fails the
    configured file-type accept list, or succeeds and shows the
    "uploading" status, the crafted <img onerror=...> payload is
    inserted directly into the DOM via showMessage's
    insertAdjacentHTML call and executes immediately, no further user
    interaction required beyond the initial drag/drop/paste.
  5. Given the Electron window's nodeIntegration:true/
    contextIsolation:false configuration, the payload's
    require("child_process") call succeeds and executes an arbitrary OS
    command at the user's privileges.

(Not run end-to-end in a live Electron instance in this review, static
analysis of the TypeScript source only; every line quoted above is read
directly from the current source tree, and the shared sink's lack of
escaping, plus the two sibling call sites that do escape and confirm
what the fix pattern looks like, are directly verifiable by any reader
of the same files.)

Second instance: AI provider connection-test responses, app/src/config/tabs/aiUi.ts

A second, mechanistically different reflection path into the same
unescaped showMessage sink was found in the AI provider configuration
UI, app/src/config/tabs/aiUi.ts (around lines 886 and 926):

fetchPost("/api/ai/listModels", {provider: providerId}, (response) => {
    ...
    const data = response.data || {};
    const models: string[] = Array.isArray(data.models) ? data.models : [];
    if (models.length === 0) {
        showMessage(`${window.siyuan.languages.fetchAvailableModelsFail}${data.msg ? ":" + data.msg : ""}`, undefined, "error");
        return;
    }
    ...
});
...
fetchPost("/api/ai/testModel", {provider: providerId, model: modelName}, (response) => {
    ...
    const available = data.available;
    if (Array.isArray(available) && available.length > 0) {
        showMessage(`${window.siyuan.languages.testConnectionFailModelNotFound}${available.slice(0, 10).join(", ")})`, undefined, "error");
        return;
    }
    ...
});

data.msg and available (a list of "available model" names) both
originate from the kernel's /api/ai/listModels and /api/ai/testModel
endpoints, which in turn proxy the response from whichever AI provider
endpoint the user has configured. Neither value is escaped before
reaching showMessage. Unlike the upload-filename instance above, the
trigger here is a malicious or compromised third-party API response
rather than a local file, a user who configures a rogue or
subsequently-compromised OpenAI-compatible endpoint (including a
"helpful" third-party proxy service that looks legitimate at
configuration time) receives whatever HTML/script content that
endpoint's error message or model-list response contains, executed with
the same full OS-command-execution consequence via the shared
Electron configuration. This is CWE-79 via CWE-829 (Inclusion of
Functionality from Untrusted Control Sphere), the AI provider is
explicitly untrusted third-party infrastructure the user points the app
at, not local content the user directly controls.

Reproduction: configure a custom AI provider endpoint pointing at a
server the tester controls; have that server's /v1/models-equivalent
(or whatever endpoint /api/ai/listModels//api/ai/testModel
ultimately calls) return an error message or model name containing an
<img onerror=...>/<script> payload; click "Fetch models" or "Test
connection" in the AI provider configuration dialog; observe the
payload executes via the same unescaped showMessage sink.

Impact

Any SiYuan desktop user who drags, drops, or pastes a file with an
attacker-crafted name into any open document, an extremely low-friction
action compared to clicking a suspicious link, triggers immediate
arbitrary OS command execution at their own privilege level. This
requires no social engineering beyond getting a file with a specific
name in front of the victim (e.g. as an email attachment, a file shared
via a folder sync, a download from a compromised or malicious site, or
a USB drive), and no interaction with the file's content is needed at
all, only its name and the act of dragging it into the app.


## Affected products

| Field | Value |
|---|---|
| Ecosystem | **npm** |
| Package name | `siyuan` (desktop application, `app/` directory) |
| Affected versions | Present at current HEAD (commit `eef1056`/`1673b75`, reviewed 2026-08-03); given the vendor's most recent related fix was for v3.7.1, likely affects all versions up to and including current, since this specific call site was never touched by any of the 8 published fixes in this sink's history. |
| Patched versions | *(none yet, leave blank until a fix is released)* |

## Severity

| Field | Value |
|---|---|
| Vector string | `CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H` |
| Score | **8.4 (High)**, matching the severity class the vendor has assigned to the other 7-8 advisories against this same sink/root-cause chain. Local vector since it requires the file to reach the victim's machine and be dragged into the app, low complexity, no privileges required by the attacker, user interaction required (the drag/drop/paste action, which is far lower-friction than clicking a link), scope change since the injected script escapes the renderer's normal boundary via the Electron configuration, complete confidentiality/integrity/availability impact once OS command execution is achieved. |

## Weaknesses (CWE)

- **CWE-79**: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (primary)
- **CWE-94**: Improper Control of Generation of Code ('Code Injection') (resulting impact, via the `nodeIntegration:true` Electron configuration)

## Notes for filing
- This is the same shared-sink, same-root-cause vulnerability class as
  the vendor's own already-published chain (GHSA-6gx2-8gcr-x83f and its
  7 listed siblings), explicitly not a duplicate of any of them, since
  none involve the upload/drag-drop validation flow or `file.name`
  reflection. Worth citing that chain directly when filing and
  explicitly noting this is "another sibling reflection path in the
  same documented-but-unresolved sink," since the vendor is clearly
  already tracking this pattern.
- Given the vendor's own advisory language acknowledges roughly 20
  reflection paths into this sink and only 7-8 have been fixed
  individually, strongly recommend the fix be applied at the sink
  (`showMessage()`/`insertAdjacentHTML` itself, e.g. by requiring
  callers to pass pre-sanitized HTML explicitly, or by defaulting to
  `textContent`-safe insertion unless a caller opts into raw HTML) rather
  than continuing to patch individual call sites, exactly as the
  existing advisory text already recommends.

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Improper Control of Generation of Code ('Code Injection')

The product constructs all or part of a code segment using externally-influenced input from an upstream component, but it does not neutralize or incorrectly neutralizes special elements that could modify the syntax or behavior of the intended code segment. Learn more on MITRE.

Credits