Skip to content

Commit 6e9c51e

Browse files
1 parent 7af1529 commit 6e9c51e

2 files changed

Lines changed: 126 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-hjh7-r5w8-5872",
4+
"modified": "2026-04-22T20:51:22Z",
5+
"published": "2026-04-22T20:51:22Z",
6+
"aliases": [],
7+
"summary": "SiYuan: Path Traversal via Double URL Encoding in `/export/` Endpoint (Incomplete Fix Bypass for CVE-2026-30869)",
8+
"details": "### Summary\nThe fix for CVE-2026-30869 in SiYuan v3.5.10 only added a denylist check (`IsSensitivePath`) but did not address the root cause — a redundant `url.PathUnescape()` call in `serveExport()`. An authenticated attacker can use double URL encoding (`%252e%252e`) to traverse directories and read arbitrary workspace files including the full SQLite database (`siyuan.db`), kernel log, and all user documents.\n\n### Details\nIn `kernel/server/serve.go`, the `serveExport()` function (line 314-320) processes file paths as follows:\n\n```go\nfilePath := strings.TrimPrefix(c.Request.URL.Path, \"/export/\")\ndecodedPath, err := url.PathUnescape(filePath) // second decode\nfullPath := filepath.Join(exportBaseDir, decodedPath)\n```\n\nGo's HTTP server already decodes percent-encoded characters once during request parsing. The additional `url.PathUnescape()` call creates a double-decode vulnerability:\n\n1. Attacker sends: `GET /export/%252e%252e/siyuan.db`\n2. Go HTTP decodes `%25` → `%`, result: `URL.Path = /export/%2e%2e/siyuan.db`\n3. Go's path cleaner sees `%2e%2e` as literal characters (not `..`), no redirect occurs\n4. `url.PathUnescape(\"%2e%2e\")` decodes to `..`\n5. `filepath.Join(exportBaseDir, \"../siyuan.db\")` resolves to `<workspace>/temp/siyuan.db`\n\nThe CVE-2026-30869 fix added `IsSensitivePath()` which blocks `<workspace>/conf/` and OS-level paths (`/etc`, `/root`, etc.). However, it does NOT block:\n- `<workspace>/temp/siyuan.db` — full document database\n- `<workspace>/temp/blocktree.db` — block tree database\n- `<workspace>/temp/siyuan.log` — kernel log\n- `<workspace>/temp/asset_content.db` — asset content database\n\nNote: the `/appearance/` handler in the same file correctly uses `gulu.File.IsSubPath()` to validate paths (line 447), but this check is missing from the `/export/` handler.\n\n### PoC\n[poc.zip](https://github.com/user-attachments/files/26866234/poc.zip)\nPlease extract the uploaded compressed file before proceeding\n\n1. docker compose up -d --build\n2. sh poc.sh\n\n<img width=\"550\" height=\"184\" alt=\"스크린샷 2026-04-19 오후 5 08 30\" src=\"https://github.com/user-attachments/assets/6aea4334-0b5a-4f45-bd1f-ecfad61ba524\" />\n\n\n\n\n### Impact\n- Data exfiltration: An authenticated user (including low-privilege Publish/Reader users via the Publish service) can download the entire SQLite document database containing all blocks, documents, attributes, and full-text search indexes.\n- Information disclosure: Kernel log (`siyuan.log`) leaks internal server paths, versions, configuration details, and error messages.",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "Go",
19+
"name": "github.com/siyuan-note/siyuan/kernel"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "3.6.5"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/siyuan-note/siyuan/security/advisories/GHSA-hjh7-r5w8-5872"
40+
},
41+
{
42+
"type": "WEB",
43+
"url": "https://github.com/siyuan-note/siyuan/commit/bb481e1290c4a34255652ede85a546504505d2a7"
44+
},
45+
{
46+
"type": "ADVISORY",
47+
"url": "https://github.com/advisories/GHSA-2h2p-mvfx-868w"
48+
},
49+
{
50+
"type": "PACKAGE",
51+
"url": "https://github.com/siyuan-note/siyuan"
52+
},
53+
{
54+
"type": "WEB",
55+
"url": "https://github.com/siyuan-note/siyuan/releases/tag/v3.6.5"
56+
}
57+
],
58+
"database_specific": {
59+
"cwe_ids": [
60+
"CWE-22"
61+
],
62+
"severity": "HIGH",
63+
"github_reviewed": true,
64+
"github_reviewed_at": "2026-04-22T20:51:22Z",
65+
"nvd_published_at": null
66+
}
67+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-p3h2-2j4p-p83g",
4+
"modified": "2026-04-22T20:50:19Z",
5+
"published": "2026-04-22T20:50:19Z",
6+
"aliases": [],
7+
"summary": "MCPHub has Path Traversal via Malicious MCPB Manifest Name",
8+
"details": "The MCPB file upload handler extracts a ZIP file and reads `manifest.json` from it. The `name` field from the manifest is concatenated directly into the file path (line 107) without any sanitization or path traversal character validation. An attacker can craft a malicious MCPB file with `manifest.name` set to `'../../../etc/malicious'` or similar, causing files to be extracted to arbitrary locations on the file system. The `cleanupOldMcpbServer` function (line 110) also uses the unsanitized name, potentially allowing arbitrary directory deletion.\n\n## 1. Summary\n- **Vulnerability Type**: Path Traversal\n- **Flagged Location**: `src/controllers/mcpbController.ts:107`\n- **Vulnerability Description**: The `name` field in the uploaded MCPB manifest is used directly to construct file system paths for directory creation and move operations without sanitization or normalization, potentially leading to a path traversal attack.\n\n## 2. Analysis Logic\n\n### Step 1: Inspect the flagged sink (`src/controllers/mcpbController.ts:106-116`)\nI reviewed the upload handler and located the file system sink where `manifest.name` is used to construct the final extraction path and where files are written to that path.\n\n```ts\n// src/controllers/mcpbController.ts:106-116\n// Use server name as the final extract directory for automatic version management\nconst finalExtractDir = path.join(path.dirname(mcpbFilePath), `server-${manifest.name}`);\n\n// Clean up any existing version of this server\ncleanupOldMcpbServer(manifest.name);\nif (!fs.existsSync(finalExtractDir)) {\n fs.mkdirSync(finalExtractDir, { recursive: true });\n}\n\n// Move the temporary directory to the final location\nfs.renameSync(tempExtractDir, finalExtractDir);\n```\n\nAnalysis: `manifest.name` is used to build `finalExtractDir`, which is then operated on by `fs.mkdirSync` and `fs.renameSync`. These are file system write/move operations, so if `name` is user-controllable and unsanitized, this is a path traversal sink. Next, I traced the source of `manifest.name`.\n\n### Step 2: Trace the source of `manifest.name` in the upload handler (`src/controllers/mcpbController.ts:83-104`)\nI traced the data flow backward to see how the manifest is read and validated.\n\n```ts\n// src/controllers/mcpbController.ts:83-104\nconst manifestPath = path.join(tempExtractDir, 'manifest.json');\nif (!fs.existsSync(manifestPath)) {\n throw new Error('manifest.json not found in MCPB file');\n}\n\nconst manifestContent = fs.readFileSync(manifestPath, 'utf-8');\nconst manifest = JSON.parse(manifestContent);\n\n// Validate required fields in manifest\nif (!manifest.manifest_version) {\n throw new Error('Invalid manifest: missing manifest_version');\n}\nif (!manifest.name) {\n throw new Error('Invalid manifest: missing name');\n}\n```\n\nAnalysis: `manifest` is parsed directly from the `manifest.json` inside the uploaded archive. The only check on `manifest.name` is non-emptiness; there is no sanitization, normalization, or whitelist validation. Next, I confirmed the entry point for uploading MCPB files to verify user control.\n\n### Step 3: Trace the HTTP entry point in `src/routes/index.ts:297-299`\nI located the route that exposes the upload handler.\n\n```ts\n// src/routes/index.ts:297-299\n// MCPB upload routes\nrouter.post('/mcpb/upload', uploadMiddleware, uploadMcpbFile);\n```\n\nAnalysis: The `/mcpb/upload` endpoint invokes `uploadMiddleware` and `uploadMcpbFile`, so user-supplied uploads are the source of the manifest content. Next, I confirmed the behavior of the upload middleware.\n\n### Step 4: Confirm the upload middleware (`src/controllers/mcpbController.ts:8-38`)\nI examined how uploaded files are received and stored.\n\n```ts\n// src/controllers/mcpbController.ts:8-38\nconst storage = multer.diskStorage({\n destination: (_req, _file, cb) => {\n const uploadDir = path.join(process.cwd(), 'data/uploads/mcpb');\n if (!fs.existsSync(uploadDir)) {\n fs.mkdirSync(uploadDir, { recursive: true });\n }\n cb(null, uploadDir);\n },\n filename: (_req, file, cb) => {\n const timestamp = Date.now();\n const originalName = path.parse(file.originalname).name;\n cb(null, `${originalName}-${timestamp}.mcpb`);\n },\n});\n\nconst upload = multer({\n storage,\n fileFilter: (_req, file, cb) => {\n if (file.originalname.endsWith('.mcpb')) {\n cb(null, true);\n } else {\n cb(new Error('Only .mcpb files are allowed'));\n }\n },\n limits: {\n fileSize: 500 * 1024 * 1024, // 500MB limit\n },\n});\n\nexport const uploadMiddleware = upload.single('mcpbFile');\n```\n\nAnalysis: The upload middleware only checks the file extension and size. It does not restrict or validate the contents of the archive or `manifest.name`. Therefore, `manifest.name` is user-controllable input. Next, I checked whether any sanitization or normalization is applied before reaching the sink.\n\n### Step 5: Verify the lack of path validation for `manifest.name` at `src/controllers/mcpbController.ts:92-110`\nI verified that no path sanitization is performed between parsing and use.\n\n```ts\n// src/controllers/mcpbController.ts:92-110\nif (!manifest.name) {\n throw new Error('Invalid manifest: missing name');\n}\n// ...\nconst finalExtractDir = path.join(path.dirname(mcpbFilePath), `server-${manifest.name}`);\ncleanupOldMcpbServer(manifest.name);\n```\n\nAnalysis: There is no `path.resolve`/`realpath` check, no use of `basename()`, and no whitelist validation before `manifest.name` is used to construct the file system path. This confirms the path is built from untrusted input with no defenses.\n\n### Step 6: Inspect cleanup behavior using the unsanitized name (`src/controllers/mcpbController.ts:41-52`)\nI verified how `cleanupOldMcpbServer` uses the same input.\n\n```ts\n// src/controllers/mcpbController.ts:41-52\nconst uploadDir = path.join(process.cwd(), 'data/uploads/mcpb');\nconst serverPattern = `server-${serverName}`;\n\nif (fs.existsSync(uploadDir)) {\n const files = fs.readdirSync(uploadDir);\n files.forEach((file) => {\n if (file.startsWith(serverPattern)) {\n const filePath = path.join(uploadDir, file);\n if (fs.statSync(filePath).isDirectory()) {\n fs.rmSync(filePath, { recursive: true, force: true });\n }\n }\n });\n}\n```\n\nAnalysis: `serverName` is used without validation, but the deletion operation is constrained to directories that already exist within `uploadDir` as returned by `readdirSync`. The primary traversal risk remains in the path construction for `finalExtractDir` and the subsequent file system operations.\n\n### Analysis Process\n- Q1: Does user-controllable input influence the file path? → **Yes**. `manifest.name` is read from the uploaded archive's `manifest.json` and used in `path.join(...)` to construct `finalExtractDir` (`src/controllers/mcpbController.ts:89-110`).\n- Q2: Is the path normalized and validated against a base directory? → **No**. There is no `resolve`/`realpath` + `startsWith` check before `fs.mkdirSync`/`fs.renameSync` (`src/controllers/mcpbController.ts:106-116`).\n- Q3: Is `basename()`/`getName()` used to strip directory components? → **No**. `manifest.name` is used directly in a template string (`src/controllers/mcpbController.ts:106-107`).\n- Q4: Is there an effective allow-list of valid file names? → **No**. Only an existence check is performed on `manifest.name` (`src/controllers/mcpbController.ts:92-97`).\n- Q5: Is the code in a test/demo/deprecated/generated context? → **No**. This is a production controller and route (`src/controllers/mcpbController.ts:64-130`, `src/routes/index.ts:297-299`).\n- → Reached leaf node: **Real Vulnerability** (TP)\n\n## 3. Conclusion\n**Real Vulnerability**\n\n**Key Evidence:**\n- `manifest.name` flows directly into `finalExtractDir` and is used by `fs.mkdirSync` and `fs.renameSync` without sanitization (`src/controllers/mcpbController.ts:106-116`).\n- `manifest.name` is parsed from the `manifest.json` inside the uploaded archive with only a non-empty check (`src/controllers/mcpbController.ts:89-97`).\n- The `/mcpb/upload` endpoint exposes the upload handler that processes user-supplied archives (`src/routes/index.ts:297-299`).\n\n## 4. Remediation Recommendations\n- Before using `manifest.name` to construct `finalExtractDir`, add normalization and base-directory validation (e.g., `` const resolved = path.resolve(baseDir, `server-${safeName}`); if (!resolved.startsWith(baseDir)) reject; ``).\n- Use `path.basename()` to strip directory components from `manifest.name`, and enforce a strict character whitelist (letters, digits, `_`, `-`, `.`) before use.\n- Consider rejecting any `manifest.name` containing path separators or traversal sequences, and add unit tests for traversal inputs.\n\n---\n\n*Translated from: Chinese (Simplified) to English using GitHub Copilot*",
9+
"severity": [
10+
{
11+
"type": "CVSS_V4",
12+
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N"
13+
}
14+
],
15+
"affected": [
16+
{
17+
"package": {
18+
"ecosystem": "npm",
19+
"name": "@samanhappy/mcphub"
20+
},
21+
"ranges": [
22+
{
23+
"type": "ECOSYSTEM",
24+
"events": [
25+
{
26+
"introduced": "0"
27+
},
28+
{
29+
"fixed": "0.12.13"
30+
}
31+
]
32+
}
33+
]
34+
}
35+
],
36+
"references": [
37+
{
38+
"type": "WEB",
39+
"url": "https://github.com/samanhappy/mcphub/security/advisories/GHSA-p3h2-2j4p-p83g"
40+
},
41+
{
42+
"type": "WEB",
43+
"url": "https://github.com/samanhappy/mcphub/commit/af5b013c09bb0add6b7ad9aaa5b875cf150d2a7c"
44+
},
45+
{
46+
"type": "PACKAGE",
47+
"url": "https://github.com/samanhappy/mcphub"
48+
}
49+
],
50+
"database_specific": {
51+
"cwe_ids": [
52+
"CWE-22"
53+
],
54+
"severity": "HIGH",
55+
"github_reviewed": true,
56+
"github_reviewed_at": "2026-04-22T20:50:19Z",
57+
"nvd_published_at": null
58+
}
59+
}

0 commit comments

Comments
 (0)