Skip to content

PDF annotation fields are written to disk unparsed and rendered into five raw attributes, turning a shared PDF into Remote Code Execution on the desktop client

Critical
88250 published GHSA-fqpw-c3pj-w8g9 Aug 1, 2026

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

master and dev

Patched versions

v3.7.4

Description

CVE: This vulnerability corresponds to CVE-2026-73041.

Summary

/api/asset/setFileAnnotation writes a client-supplied string straight to disk with no parsing or validation. showHighlight then interpolates five fields from that file into HTML attributes with no escaping and inserts the result with insertAdjacentHTML, for every annotation on every rendered PDF page.

The annotation sidecar travels with its PDF through export, import, synchronisation and rename, so a shared or imported document carries the payload with it.

Two lines below the affected template, the author writes one further field using setAttribute, which is injection-proof.

Details

The sink. app/src/asset/anno.ts:682 on dev, :683 on master:

let html = `<div class="pdf__rect popover__block" data-node-id="${selected.id}"
  data-relations="${selected.ids || ""}" data-mode="${selected.mode}"
  data-type="${selected.type}" style="--pdf-annotation-color: ${selected.color}">`;
...
rectsElement.insertAdjacentHTML("beforeend", html + "</div>");

Five interpolations, none escaped. Master carries the first three; data-type and the style colour are a more recent addition. Both refs are exploitable, because ids is the first field that breaks out.

This runs from showHighlight() for every annotation on every rendered page of a PDF.

One field in the same block is handled correctly. Two lines below:

rectsElement.lastElementChild.setAttribute("data-content", selected.content);

setAttribute cannot be broken out of. The author addressed exactly this concern for content and left the five fields in the template literal untreated.

The kernel never parses the file. /api/asset/setFileAnnotation reads arg["data"].(string) and calls filelock.WriteFile(writePath, []byte(data)). The only inspection in the handler is if "{}" == data { remove }. There is no JSON unmarshal, no schema, no length or character constraint. The file is an opaque client blob persisted verbatim.

Confirmed live. An annotation entry was written into an existing .sya fixture, preserving its structure, then read back through /api/asset/getFileAnnotation and verified on disk. All payload fields were byte-identical at every stage. Parsing the HTML that showHighlight emits from that file produces:

<div> { class: "pdf__rect popover__block", data-node-id: "…", data-relations: "a" }
<img> { src: "x", onerror: "alert('PDFIDS')"  }    ← from selected.ids
<img> { src: "x", onerror: "alert('PDFTYPE')" }    ← from selected.type

Two genuine event-handler elements, from two independent fields.

Delivery. The .sya sidecar is not a local artefact. It is included in IncSync() on write, copied on export at kernel/model/export.go:2578-2582 and read on import at :4012, and renamed alongside its asset at kernel/model/assets.go:1506-1514. A shared notebook, an imported package or a synchronised workspace that carries a PDF carries its annotations too. The payload executes when the recipient opens the PDF.

Consequence in the desktop application. app/electron/main.js sets nodeIntegration: true, contextIsolation: false and webSecurity: false on every window (lines 913, 1019-1022, 1933-1936, 2157, 2197, 2248), so script executing in a renderer reaches Node built-ins including require('child_process').

Scope of observation. The write path, the round trip through the API, the on-disk contents and the parsed output above are confirmed as described. I have not observed handler invocation in a running client, so the step from the parsed attribute to execution is stated from the Electron configuration rather than from a captured runtime event.

Relationship to existing advisories. pdf__rect, showHighlight, anno.ts, pdf-annotation-color and selected.color each return no match across the published advisories for this project.

setFileAnnotation, getFileAnnotation and .sya match only GHSA-v7ph-r5r6-4jcj, which concerns publish-access filtering on the read endpoint. That advisory reports an information disclosure and does not touch anno.ts, the render path or escaping. It is relevant here only as established context: it confirms that /api/asset/getFileAnnotation carries CheckAuth alone and is reachable by publish readers.

Proof of Concept

Write an annotation whose ids field contains markup:

POST /api/asset/setFileAnnotation
{"path":"<asset path>.sya","data":"<annotation JSON with
   ids = a\"><img src=x onerror=alert('PDFIDS')>"}

→ {"code":0}

Confirm the round trip:

POST /api/asset/getFileAnnotation → returned verbatim
on disk                           → byte-identical

Then open the PDF in the client. showHighlight renders every annotation on the page.

Impact

An attacker-supplied annotation file executes script when a user opens the associated PDF. Because the renderer runs with nodeIntegration: true and contextIsolation: false, that script reaches Node built-ins including require('child_process'), so execution is not confined to the page.

The delivery path is ordinary product behaviour rather than a contrived scenario: annotation sidecars are exported, imported, synchronised and renamed alongside their PDFs. Sharing a notebook containing an annotated PDF, or importing a package containing one, is sufficient. No interaction beyond opening the document is required, and every annotation on every page is rendered through the affected code.

Suggested fix

Build the element with setAttribute for every field, matching what the following line already does for content. That removes the injection surface entirely rather than escaping around it.

If the template literal is retained, apply escapeAttr to all five interpolations and constrain color to a palette index or hex pattern.

Server-side, setFileAnnotation should unmarshal the payload into a typed structure and re-serialise it, rather than persisting an opaque client blob. That would also give the field constraints a place to live.

Severity

Critical

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
Network
Attack complexity
Low
Privileges required
Low
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:N/AC:L/PR:L/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.

Credits