Skip to content

Zero-click XSS-to-RCE via unescaped block name/alias/memo in the "((" block-reference autocomplete hint popup (app/src/protyle/hint/extend.ts), fires on render with no user interaction beyond typing a reference

High
88250 published GHSA-5prr-vgxq-69g9 Aug 5, 2026

Package

npm siyuan (npm)

Affected versions

3.7.3

Patched versions

None

Description

The most severe and lowest-friction of this review round's frontend
findings. Same field family (name/alias/memo) as the companion "move to
path" advisory, but a different function, different file, and a
dramatically lower-friction trigger: the payload can execute the moment
the autocomplete dropdown renders, with no click and no hover required,
via a self-firing tag like <img onerror=...>.

Summary

Typing (( in any SiYuan document opens an autocomplete popup listing
blocks across the workspace matching the search text typed so far, one
of the most frequently used features in the editor. Each suggestion's
HTML is built by genHintItemHTML(), which concatenates the candidate
block's name, alias, and memo fields directly into the popup's
markup with no HTML-escaping at all. Because these three fields are
ordinary, user-settable metadata any document/block author can set, and
because rendering a self-firing payload (e.g. <img src=x onerror=...>) requires no click, no hover, and no confirmation, only
that the malicious entry be rendered as one of the search results, this
is a zero-interaction-beyond-typing trigger: a victim only needs to type
(( followed by any search term broad enough to surface the crafted
block for the payload to execute automatically. Given every SiYuan
BrowserWindow runs with nodeIntegration:true,
contextIsolation:false, and no CSP, the payload has immediate
require("child_process") access. This is CWE-79 (Cross-Site
Scripting) escalating to CWE-94 (Code Injection) via the Electron
configuration.

Details

app/src/protyle/hint/extend.ts, genHintItemHTML() (around line 424):

export const genHintItemHTML = (item: IBlock) => {
    let iconHTML;
    ...
    let attrHTML = "";
    if (item.name) {
        attrHTML += `<span class="fn__flex">...<span>${item.name}</span></span>...`;
    }
    if (item.alias) {
        attrHTML += `<span class="fn__flex">...<span>${item.alias}</span></span>...`;
    }
    if (item.memo) {
        attrHTML += `<span class="fn__flex">...<span>${item.memo}</span></span>`;
    }
    ...
};

item.name, item.alias, and item.memo are interpolated directly as
element text content between <span> tags, no escapeHtml(),
Lute.EscapeHTMLStr(), or equivalent anywhere in the function.

Reachability confirmed directly: genHintItemHTML() is called from
hintRef() (app/src/protyle/hint/extend.ts:458), the function backing
the (( block-reference search:

export const hintRef = (key: string, protyle: IProtyle, source: THintSource): IHintData[] => {
    ...
    response.data.blocks.forEach((item: IBlock) => {
        ...
        dataList.push({
            value,
            html: genHintItemHTML(item),
        });
    });
    ...
};

response.data.blocks comes directly from the kernel's block-search API
response for whatever the user has typed after (( so far, i.e. it is
populated with real blocks from the workspace matching the search text,
including blocks/documents an attacker controls the metadata of. The
same function is also called from two further call sites in the same
file (lines ~500 and ~541, additional hint-list-building branches) and
from app/src/protyle/hint/index.ts:402, so this reaches every render
path of the reference/embed hint popup, not just one entry point.

This shares its root field family (name/alias/memo/bookmark) with the
companion advisory filed alongside this one covering the "move to path"
dialog's tooltip (app/src/util/pathName.ts), where the same fields are
unescaped in an attribute context; this instance is a separate function,
separate file, separate UI surface, and a text-content injection
(directly exploitable via a self-firing tag, no attribute breakout
needed), which is why it is reported as its own advisory.

Step-by-step reproduction

  1. On any block or document (reachable by the victim via sharing, sync,
    import, or presence anywhere in a workspace the victim later opens),
    set the "Memo" or "Alias" custom attribute, or the block/document
    name itself, to a self-firing payload, for example:
    <img src=x onerror="require('child_process').exec('calc')">
    
  2. Get this content into the victim's workspace by any of the usual
    means (shared notebook, synced workspace, imported .sy/export
    bundle).
  3. In the SiYuan desktop app, open any document and type (( followed
    by any search term broad enough to surface the crafted block/document
    in the autocomplete results (an empty or very short search term is
    often sufficient, since the hint list shows recent/matching blocks by
    default).
  4. The moment the popup renders the crafted entry, the <img onerror=...> payload fires automatically, no click, no hover, no
    further interaction beyond the initial (( keystroke and whatever
    search text was already being typed for an unrelated, legitimate
    reason.
  5. Given the Electron window's nodeIntegration:true/
    contextIsolation:false configuration, require("child_process")
    succeeds, achieving arbitrary OS command execution at the victim's
    privilege level.

(Not run in a live Electron instance in this review, static analysis
of the TypeScript source only; genHintItemHTML()'s lack of escaping
and its reachability from hintRef()'s live kernel search results are
both read and traced directly from source, confirming this is a live,
frequently-exercised code path rather than a rare or dead one.)

Impact

Any SiYuan user who types (( to insert a block reference, one of the
most routine, frequent actions in the editor, and has any block or
document anywhere in their searchable workspace carrying an
attacker-crafted name, alias, or memo, executes that payload
automatically the instant the autocomplete popup renders it as a
suggestion. This requires no deliberate victim action targeting the
malicious content specifically: normal, unrelated use of the reference
feature is sufficient to trigger it, making this the lowest-friction
trigger among this review round's frontend findings.


## Affected products

| Field | Value |
|---|---|
| Ecosystem | **npm** |
| Package name | `siyuan` (desktop application, `app/src/protyle/hint/extend.ts`) |
| Affected versions | Present at current HEAD (commit `eef1056`/`1673b75`, reviewed 2026-08-03) |
| 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 | **9.0 (Critical)**. Scored above the companion frontend findings in this review round given the trigger requires no deliberate interaction with the malicious content at all, ordinary use of one of the editor's most common features (typing `((`) is sufficient once a crafted block exists anywhere reachable in the workspace. Local vector, low complexity, no privileges required by the attacker, user interaction limited to routine, unrelated editor use, scope change 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
- Recommend filing this alongside the companion "move to path" advisory
  from this review round, since both stem from the same unescaped
  field family (name/alias/memo/bookmark) but affect different
  functions, files, and injection contexts (text content here vs.
  attribute breakout there). Given the shared field family, recommend
  the maintainers grep the whole frontend for
  `item.bookmark|item.memo|item.alias|item.name1` once, this review
  found instances in five files total; two were already correctly
  escaped (`Files.ts`, `ai/actions.ts`, `history.ts`), two were not
  (`pathName.ts`, this file), suggesting a single shared, always-escaped
  formatting helper for this metadata block would prevent recurrence.
- Suggested fix: wrap `item.name`, `item.alias`, and `item.memo` in
  `escapeHtml()` (or the codebase's established `escapeMethod()`
  helper, used correctly for the same fields elsewhere) inside
  `genHintItemHTML()`.

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