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
- 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)
- Open the SiYuan desktop app to any document in the editor.
- 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.
- 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.
- 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.
Same shared sink and same Electron root cause as the vendor's published
siyuan://deep-link chain (GHSA-6gx2-8gcr-x83f and its 7 priorsiblings: 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
showMessagecall sites inconfig/index.tsandprotyle/export/util.ts, which do callescapeHtml().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 viaelement.insertAdjacentHTML(...)with no sanitization at that layereither. 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
BrowserWindowin theSiYuan desktop app runs with
nodeIntegration:true,contextIsolation:false,webSecurity:false, and no CSP (the sameconfiguration 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 atthe 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):file.nameis the browser/OS-supplied filename of whatever file theuser 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 separatepoints in this function (the two
errorTipbranches and theuploadingStrbranch, the latter using the lightly-transformedfilenamevariable, which only strips/replaces the extension viaprotyle.options.upload.filename(...), not HTML-escapes it).showMessage()(app/src/dialog/message.ts, confirmed by directreading) inserts its
messageargument via.insertAdjacentHTML(...)with no escaping of its own:
where
messageVersionis derived directly from the caller-suppliedmessage.For contrast, two other
showMessagecall sites that also interpolateexternal strings were fixed at the sink by wrapping the value in
escapeHtml():validateFile()'s three interpolations offile.name/filenamewerenot 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
for example:
filename restrictions, e.g. substituting characters the local
filesystem disallows; the core payload shape is unaffected)
document (or paste it via clipboard, or drop it onto the editor
area), triggering the normal "insert attachment/image" upload flow.
configured file-type accept list, or succeeds and shows the
"uploading" status, the crafted
<img onerror=...>payload isinserted directly into the DOM via
showMessage'sinsertAdjacentHTMLcall and executes immediately, no further userinteraction required beyond the initial drag/drop/paste.
nodeIntegration:true/contextIsolation:falseconfiguration, the payload'srequire("child_process")call succeeds and executes an arbitrary OScommand 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.tsA second, mechanistically different reflection path into the same
unescaped
showMessagesink was found in the AI provider configurationUI,
app/src/config/tabs/aiUi.ts(around lines 886 and 926):data.msgandavailable(a list of "available model" names) bothoriginate from the kernel's
/api/ai/listModelsand/api/ai/testModelendpoints, 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, thetrigger 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/testModelultimately calls) return an error message or model name containing an
<img onerror=...>/<script>payload; click "Fetch models" or "Testconnection" in the AI provider configuration dialog; observe the
payload executes via the same unescaped
showMessagesink.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.