Skip to content

Path Traversal in MCP tool database_clean (RemoveUnusedAttributeView) leads to Arbitrary File Read (via history copy) and Arbitrary File Deletion, missed by the recent GHSA-7hm9-v7vf-7g4w fix

High
88250 published GHSA-43jx-gxq4-jpjc Aug 3, 2026

Package

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

Affected versions

3.7.3

Patched versions

v3.7.4

Description

Found via a different technique than the rest of this review round:
tracing the pattern of a very recently fixed advisory
(GHSA-7hm9-v7vf-7g4w, "Validate attribute view IDs before file access",
merged commit 0f5a0e7, 2026-07-20) to check whether every caller of the
function it protects received the same fix. One did not.

Summary

RemoveUnusedAttributeView(id string) (kernel/model/attribute_view.go:50)
builds a filesystem path directly from its id parameter via
filepath.Join(base, id+".json"), with no validation that id matches
SiYuan's node-ID format. If id contains path traversal sequences, the
resulting path can point outside the intended data/storage/av
directory to any file the kernel process can read. The function then
copies that file into SiYuan's own history directory before deleting the
original, giving arbitrary file read and arbitrary file deletion from a
single unvalidated string. The HTTP API handler for this exact
operation, kernel/api/av.go's removeUnusedAttributeView, was
correctly hardened for this in the recent fix for GHSA-7hm9-v7vf-7g4w,
which added ast.IsNodeIDPattern()/util.InvalidIDPattern() checks
throughout the kernel/av package and its HTTP callers. However, the
MCP tool that calls the same underlying function,
kernel/mcp/tools/database.go's database_clean, was not updated and
performs only an empty-string check before calling
RemoveUnusedAttributeView(id) directly. This is CWE-22 (Path
Traversal), specifically a fix that was applied to one caller of a
shared function but not to a sibling caller of the same function.

Details

Vulnerable function, unchanged by the recent fix,
kernel/model/attribute_view.go:50-77:

func RemoveUnusedAttributeView(id string) {
    base := filepath.Join(util.DataDir, "storage", "av")
    absPath := filepath.Join(base, id+".json")
    if !filelock.IsExist(absPath) {
        return
    }

    historyDir, err := getHistoryDir(HistoryOpClean)
    ...
    newP := strings.TrimPrefix(absPath, util.DataDir)
    historyPath := filepath.Join(historyDir, newP)
    if filelock.IsExist(absPath) {
        if err = filelock.Copy(absPath, historyPath); err != nil {
            return
        }
    }

    if err = filelock.RemoveWithoutFatal(absPath); err != nil {
        ...
    }
    ...
}

No check that id matches ast.IsNodeIDPattern() exists anywhere in
this function.

Correctly-fixed HTTP caller, kernel/api/av.go:34-47:

func removeUnusedAttributeView(c *gin.Context) {
    ...
    avID := arg["id"].(string)
    if util.InvalidIDPattern(avID, ret) {
        return
    }
    model.RemoveUnusedAttributeView(avID)
    ...
}

Unfixed MCP tool caller, kernel/mcp/tools/database.go:316-323:

func databaseClean(args map[string]any) (CallToolResult, error) {
    id, _ := args["id"].(string)
    if id != "" {
        model.RemoveUnusedAttributeView(id)
        return CallToolResult{Content: []ContentItem{{Type: "text", Text: "unused database cleaned: " + id}}}, nil
    }
    removed := model.RemoveUnusedAttributeViews()
    return CallToolResult{Content: []ContentItem{{Type: "text", Text: fmt.Sprintf("%d unused database(s) cleaned", len(removed))}}}, nil
}

The only check is id != "". No ID-format validation, no path
containment check.

The other call sites of filepath.Join(..., id+".json")-style patterns
in the same file (RemoveUnusedAttributeViews(), the bulk-clean
variant, and UnusedAttributeViews()) are not affected: their id
values are always drawn from getAllAvIDs()/unusedAv.Item, i.e.
internally computed by scanning the real contents of the
storage/av directory, never from an external caller's raw string.
RemoveUnusedAttributeView (singular) is the only instance that accepts
an externally-supplied id directly, which is exactly why the recent
security fix added validation to the two callers it covered, and exactly
why this third caller, reachable via MCP, is a real gap rather than a
theoretical one.

The /mcp endpoint itself requires authentication
(kernel/mcp/server.go:31, model.CheckAuth, model.CheckAdminRole, model.CheckReadonly), so this is not remotely exploitable by an
unauthenticated party. The risk is specifically the MCP threat model:
SiYuan's MCP feature is designed to let an AI agent act on the user's
workspace with tool calls like this one. An agent that is manipulated,
via prompt injection from untrusted content it processes, into calling
database_clean with a crafted id would carry out this file
read/delete with the full privileges of the authenticated session,
despite the tool's apparent scope being "clean up unused databases
within my notes," not "read or delete arbitrary files on the host."
This is the same class of confused-deputy risk that MCP tool
authorization boundaries exist to prevent, and it is a materially
different threat model than a human deliberately typing raw admin
commands, which is why the maintainers' own recent fix treated this
exact function as needing hardening in the first place.

PoC

// MCP tool call, using an already-authenticated MCP session:
{
  "name": "database_clean",
  "arguments": {
    "id": "../../../../../../home/user/.ssh/id_rsa"
  }
}

Trace through RemoveUnusedAttributeView:

  1. absPath := filepath.Join(base, id+".json") resolves outside
    data/storage/av to the target file (subject to how many ../
    segments are needed to reach the target from that base directory).
  2. filelock.IsExist(absPath) succeeds if the target file exists.
  3. filelock.Copy(absPath, historyPath) copies the target file's full
    contents into SiYuan's history directory, arbitrary file read.
  4. filelock.RemoveWithoutFatal(absPath) deletes the original file,
    arbitrary file deletion.

(Not run against a live compiled kernel, same sandbox limitation noted
throughout this review; the vulnerable code path, the already-fixed
sibling, and the unfixed MCP caller are all read directly from source at
commit eef1056/1673b75... i.e. current HEAD as of this review,
verified against the actual fix commit 0f5a0e7 for GHSA-7hm9-v7vf-7g4w
to confirm this specific function and caller were not part of that fix.)

Impact

Any MCP client connected to a SiYuan instance (most realistically, an AI
agent granted MCP tool access by the workspace owner for note-management
tasks) can be induced, directly or via a prompt-injection chain, to read
the contents of any file the kernel process can access into SiYuan's own
history (from which it becomes retrievable through the ordinary,
already-authenticated history API) and to delete that file from its
original location. This bypasses the workspace boundary the MCP tool
surface is meant to operate within, and does so via the exact function
the maintainers already identified as needing ID validation for this
reason, just through a caller their fix did not reach.


## Affected products

| Field | Value |
|---|---|
| Ecosystem | **Go** |
| Package name | `github.com/siyuan-note/siyuan/kernel` |
| Affected versions | Present at current HEAD (commit `eef1056`/`1673b75`, reviewed 2026-08-03), i.e. after the fix for GHSA-7hm9-v7vf-7g4w (commit `0f5a0e7`, 2026-07-20), confirming this specific gap postdates and was not covered by that fix. |
| Patched versions | *(none yet, leave blank until a fix is released)* |

## Severity

| Field | Value |
|---|---|
| Vector string | `CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:N` |
| Score | **7.2 (High)**. Network vector via the `/mcp` endpoint, low complexity, high privileges required (admin-level auth to reach `/mcp` at all, `PR:H`), no user interaction needed once an MCP session exists, scope change since the impact escapes the intended workspace-data boundary into the host filesystem, high confidentiality and integrity impact (arbitrary file read and delete), no availability impact beyond the deleted file itself. Maintainers may wish to weigh the prompt-injection/confused-deputy angle when assessing real-world severity, since it meaningfully lowers the practical bar below "the account owner deliberately attacks themselves." |

## Weaknesses (CWE)

- **CWE-22**: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (primary)
- **CWE-73**: External Control of File Name or Path

## Notes for filing
- This is a direct, narrowly-scoped gap in the fix for
  GHSA-7hm9-v7vf-7g4w rather than an unrelated new bug class; worth
  linking to that advisory when filing, and worth the maintainers
  double-checking whether any other callers of functions touched by that
  fix (or by the other recent "restrict X by publish access" fixes
  visible in the git history) have similar sibling-caller gaps, since
  this is now the second time in this codebase's history that a fix
  applied to one caller of a shared function missed another.
- Suggested fix: add the same `ast.IsNodeIDPattern(id)` /
  `util.InvalidIDPattern` check at the top of
  `RemoveUnusedAttributeView` itself (defense-in-depth, protects every
  current and future caller at once) rather than only at individual call
  sites, which is what allowed this specific caller to be missed.

Severity

High

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
High
User interaction
None
Scope
Changed
Confidentiality
High
Integrity
High
Availability
None

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:H/UI:N/S:C/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

External Control of File Name or Path

The product allows user input to control or influence paths or file names that are used in filesystem operations. Learn more on MITRE.

Credits