{"record":{"id":"6c2838c41a711fdd","repo":"siyuan-note/siyuan","slug":"symlink-escapes-workspace-s","errorCode":null,"errorMessage":"symlink escapes workspace: %s","messagePattern":"symlink escapes workspace: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/mcp/tools/file.go","lineNumber":111,"sourceCode":"\tif err := authorizePath(abs, rel); err != nil {\n\t\treturn \"\", err\n\t}\n\treturn abs, nil\n}\n\n// authorizePath 校验单个最终路径是否允许访问，display 仅用于错误信息：顶层调用传工作区相对路径，\n// 递归遍历、目录拷贝和压缩包解压传最终路径本身。\nfunc authorizePath(abs, display string) error {\n\tif !gulu.File.IsSubPath(util.WorkspaceDir, abs) {\n\t\treturn fmt.Errorf(\"path escapes workspace: %s\", display)\n\t}\n\t// 拒绝加密笔记本目录：MCP 文件工具不能读写加密 box 下的文件（防止密文泄漏或明文破坏加密格式）\n\tif boxID, encrypted := rejectEncryptedPath(abs); encrypted {\n\t\treturn fmt.Errorf(\"path belongs to encrypted notebook [%s]: %s\", boxID, display)\n\t}\n\t// 防止 symlink 逃逸工作区：解析符号链接后再次检查\n\tif resolved := util.ResolveLongestExistingParent(abs); resolved != abs && !gulu.File.IsSubPath(util.WorkspaceDir, resolved) {\n\t\treturn fmt.Errorf(\"symlink escapes workspace: %s\", display)\n\t}\n\t// 禁止访问敏感文件（conf/conf.json、data/snippets/conf.json、data/templates、data/.siyuan/publishAccess.json），\n\t// 与 HTTP 文件 API 共用同一黑名单（见 kernel/util/path_guard.go 的 IsForbiddenAbsPath）\n\tif util.IsForbiddenAbsPath(abs) {\n\t\treturn fmt.Errorf(\"access to sensitive workspace file is forbidden: %s\", display)\n\t}\n\treturn nil\n}\n\n// authorizeFinalPath 对即将打开或创建的最终路径做授权。resolvePath 只覆盖调用方给出的路径，\n// 容器路径合法不代表其后代合法：递归遍历、复制、解压、删除、重命名都必须对每一个后代路径再次调用本函数。\nfunc authorizeFinalPath(abs string) error {\n\treturn authorizePath(abs, abs)\n}\n\n// authorizeSubtree 校验路径及其全部后代，任一后代被拒绝即整体拒绝。删除和重命名是目录级操作，\n// 只校验目录本身会让受保护的后代被删除或搬出黑名单范围（例如 file.delete(\"conf\")）。\n// 使用 Lstat：删除和重命名不会跟随符号链接，与 os.RemoveAll、os.Rename 的语义保持一致。","sourceCodeStart":93,"sourceCodeEnd":129,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/9f775e8a12daef8255556097396f9b2739078892/kernel/mcp/tools/file.go#L93-L129","documentation":"SiYuan's MCP file tools reject any path whose real location (after resolving symbolic links) leaves the workspace directory. authorizePath resolves the longest existing parent of the path with util.ResolveLongestExistingParent and compares it against util.WorkspaceDir using gulu.File.IsSubPath; a mismatch means a symlink (or a parent directory symlink) points outside the sandbox. This prevents MCP file tools from being used to read or write arbitrary files on the host via a planted symlink.","triggerScenarios":"Calling any MCP file tool (read/write/copy/move/remove via resolvePath, authorizeFinalPath, or authorizeArchiveEntry) with a path that is itself a symlink pointing outside the workspace, or that sits under a directory inside the workspace which is a symlink to an external location, or an archive member that extracts onto such a symlink.","commonSituations":"Users symlink data/ assets to an external disk or home-directory folder; a synced or restored workspace contains dangling or malicious symlinks; an uploaded zip contains entries that overwrite or traverse through existing symlinks inside the workspace.","solutions":["Remove the symlink inside the workspace and move the real data under the workspace directory, or replace it with a copy instead of a link","Check where the link points (ls -l / readlink) and confirm whether the target should be inside the workspace; re-anchor it there","If the link is intentional, expose the target through a supported mechanism (e.g. copy into data/) rather than a symlink","Verify with a preflight check that resolving the path stays under the workspace before calling the tool"],"exampleFix":"// before (fails: assets is a symlink to ~/Pictures)\nreadMcpFile(\"/workspace/data/assets/photo.png\")\n// after (copy the real file into the workspace)\ncp ~/Pictures/photo.png /workspace/data/assets/photo.png\nreadMcpFile(\"/workspace/data/assets/photo.png\")","handlingStrategy":"validation","validationCode":"const resolved = fs.realpathSync.native(p);\nif (!resolved.startsWith(WORKSPACE_DIR + path.sep)) {\n  throw new Error(`symlink escapes workspace: ${p}`);\n}","typeGuard":"function isInsideWorkspace(p, workspace) {\n  const { path } = require('path');\n  const rel = path.relative(workspace, p);\n  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);\n}","tryCatchPattern":null,"preventionTips":["Avoid symlinks inside the workspace; copy data in instead of linking","After syncing/restoring a workspace, scan for symlinks: find <workspace> -type l","Anchor external data under data/ before exposing it to MCP tools"],"tags":["security","filesystem","symlink","path-validation"],"backgroundTag":"path-traversal-blocked","analyzedSha":"9f775e8a12daef8255556097396f9b2739078892","analyzedAt":"2026-09-19T03:17:15.984Z","contentChangedAt":"2026-09-19T03:17:15.984Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}