{"record":{"id":"ac4c8821820b1b64","repo":"siyuan-note/siyuan","slug":"refuse-to-write-decrypted-asset-inside-workspace","errorCode":null,"errorMessage":"refuse to write decrypted asset inside workspace","messagePattern":"refuse to write decrypted asset inside workspace","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/api/file.go","lineNumber":68,"sourceCode":"\t\treturn headerApp\n\t}\n\treturn bodyApp\n}\n\n// rejectEncryptedBoxPath 检查 absPath 是否落在加密笔记本目录下（含 symlink 绕过），是则返回 true。\n// 原始文件 API（getFile/putFile/copyFile/renameFile/removeFile）是绕过加密层的逃生口，\n// 对加密笔记本的任何文件读写都应拒绝——合法读写走专用 API（upload/getBlockKramdown 等，已加密感知），\n// 避免密文泄漏给插件或明文破坏加密格式。\n// 防止 symlink 绕过：找到最长已存在的父路径，解析 symlink 后拼回剩余路径，再检查是否落入加密 box。\nfunc rejectEncryptedBoxPath(absPath string) bool {\n\treturn model.EncryptedRawPathBoxID(absPath) != \"\"\n}\n\n// copyDecryptedAsset 将加密 asset 解密后复制到目标路径（dest 必须在工作区外）。\nfunc copyDecryptedAsset(src, dest string) error {\n\t// 安全守卫：dest 必须在工作区外，防止解密后的明文落入工作区普通目录\n\tif gulu.File.IsSubPath(util.WorkspaceDir, dest) {\n\t\treturn fmt.Errorf(\"refuse to write decrypted asset inside workspace\")\n\t}\n\tboxID := model.ExtractBoxIDFromAssetsPath(src)\n\tif boxID == \"\" || !model.IsEncryptedBox(boxID) {\n\t\treturn fmt.Errorf(\"source is not an encrypted asset\")\n\t}\n\tif !model.IsBoxUnlocked(boxID) {\n\t\treturn fmt.Errorf(\"%s\", model.Conf.Language(314))\n\t}\n\tif err := model.EnsureAssetLocal(src); err != nil {\n\t\treturn err\n\t}\n\tmodel.HoldBoxReadLock(boxID)\n\tdefer model.ReleaseBoxReadLock(boxID)\n\tdek, dekErr := model.GetDEKIfUnlocked(boxID)\n\tif dekErr != nil {\n\t\treturn dekErr\n\t}\n\tdiskName := filepath.Base(src)","sourceCodeStart":50,"sourceCodeEnd":86,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/9f775e8a12daef8255556097396f9b2739078892/kernel/api/file.go#L50-L86","documentation":"copyDecryptedAsset exports a decrypted (plaintext) copy of an encrypted asset file to a destination outside the workspace. Before doing anything it guards that dest is NOT under util.WorkspaceDir, because writing decrypted plaintext back into the workspace would defeat the notebook's encryption guarantee. If dest resolves inside the workspace, the API refuses with this error.","triggerScenarios":"Calling the file export API (globalCopyFiles / file copy endpoint) with a destination path that is a sub-path of the SiYuan workspace directory, e.g. dest = <workspace>/data/assets/out.png or any path inside the workspace dir.","commonSituations":"Users pick a save location that happens to be inside the workspace folder; scripts construct dest with filepath.Join(util.WorkspaceDir, ...) or use workspace-relative paths; frontends default the download target to the workspace data dir.","solutions":["Choose a destination directory outside util.WorkspaceDir (e.g. system Downloads or a temp dir).","Resolve the caller-supplied path to an absolute path and verify with gulu.File.IsSubPath(util.WorkspaceDir, dest) before calling the API.","If the plaintext copy is truly meant to live in the workspace, use the normal asset-write API on the (locked) encrypted box instead of the decryption-export path.","Return a user-facing message pointing the user to pick a different folder instead of surfacing this internal guard verbatim."],"exampleFix":"// before\ndest := filepath.Join(util.WorkspaceDir, \"export\", \"decrypted.png\")\nerr := copyDecryptedAsset(src, dest)\n\n// after\ndest := filepath.Join(os.TempDir(), \"decrypted.png\")\nif gulu.File.IsSubPath(util.WorkspaceDir, dest) { /* re-pick dest */ }\nerr := copyDecryptedAsset(src, dest)","handlingStrategy":"validation","validationCode":"const isInside = destAbs.startsWith(workspaceDir + require(\"path\").sep)\nif (isInside) throw new Error(\"dest must be outside the workspace\")","typeGuard":"function isOutsideWorkspace(dest, workspaceDir) {\n  const rel = require(\"path\").relative(workspaceDir, dest)\n  return rel !== \"\" && !rel.startsWith(\"..\") && !require(\"path\").isAbsolute(rel) // false => inside\n}","tryCatchPattern":"try { await copyDecryptedAsset(src, dest) }\ncatch (e) { if (e.message.includes(\"inside workspace\")) promptUserForExternalFolder() else throw e }","preventionTips":["Default export destinations to the OS temp/Downloads dir, never the workspace","Resolve to absolute paths and check IsSubPath before every call","Never build dest from workspace-relative user input"],"tags":["security","encryption","filesystem","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"}