siyuan-note/siyuan · error

refuse to write decrypted asset inside workspace

Error message

refuse to write decrypted asset inside workspace

What it means

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.

Solutions

  1. Choose a destination directory outside util.WorkspaceDir (e.g. system Downloads or a temp dir).
  2. Resolve the caller-supplied path to an absolute path and verify with gulu.File.IsSubPath(util.WorkspaceDir, dest) before calling the API.
  3. 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.
  4. Return a user-facing message pointing the user to pick a different folder instead of surfacing this internal guard verbatim.

Example fix

// before
dest := filepath.Join(util.WorkspaceDir, "export", "decrypted.png")
err := copyDecryptedAsset(src, dest)

// after
dest := filepath.Join(os.TempDir(), "decrypted.png")
if gulu.File.IsSubPath(util.WorkspaceDir, dest) { /* re-pick dest */ }
err := copyDecryptedAsset(src, dest)
Defensive patterns

Strategy: validation

Validate before calling

const isInside = destAbs.startsWith(workspaceDir + require("path").sep)
if (isInside) throw new Error("dest must be outside the workspace")

Type guard

function isOutsideWorkspace(dest, workspaceDir) {
  const rel = require("path").relative(workspaceDir, dest)
  return rel !== "" && !rel.startsWith("..") && !require("path").isAbsolute(rel) // false => inside
}

Try / catch

try { await copyDecryptedAsset(src, dest) }
catch (e) { if (e.message.includes("inside workspace")) promptUserForExternalFolder() else throw e }

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19). Data as JSON: /api/errors/ac4c8821820b1b64. Report an issue: GitHub.

Appendix: source

Thrown at kernel/api/file.go:68

		return headerApp
	}
	return bodyApp
}

// rejectEncryptedBoxPath 检查 absPath 是否落在加密笔记本目录下(含 symlink 绕过),是则返回 true。
// 原始文件 API(getFile/putFile/copyFile/renameFile/removeFile)是绕过加密层的逃生口,
// 对加密笔记本的任何文件读写都应拒绝——合法读写走专用 API(upload/getBlockKramdown 等,已加密感知),
// 避免密文泄漏给插件或明文破坏加密格式。
// 防止 symlink 绕过:找到最长已存在的父路径,解析 symlink 后拼回剩余路径,再检查是否落入加密 box。
func rejectEncryptedBoxPath(absPath string) bool {
	return model.EncryptedRawPathBoxID(absPath) != ""
}

// copyDecryptedAsset 将加密 asset 解密后复制到目标路径(dest 必须在工作区外)。
func copyDecryptedAsset(src, dest string) error {
	// 安全守卫:dest 必须在工作区外,防止解密后的明文落入工作区普通目录
	if gulu.File.IsSubPath(util.WorkspaceDir, dest) {
		return fmt.Errorf("refuse to write decrypted asset inside workspace")
	}
	boxID := model.ExtractBoxIDFromAssetsPath(src)
	if boxID == "" || !model.IsEncryptedBox(boxID) {
		return fmt.Errorf("source is not an encrypted asset")
	}
	if !model.IsBoxUnlocked(boxID) {
		return fmt.Errorf("%s", model.Conf.Language(314))
	}
	if err := model.EnsureAssetLocal(src); err != nil {
		return err
	}
	model.HoldBoxReadLock(boxID)
	defer model.ReleaseBoxReadLock(boxID)
	dek, dekErr := model.GetDEKIfUnlocked(boxID)
	if dekErr != nil {
		return dekErr
	}
	diskName := filepath.Base(src)

View on GitHub (pinned to 9f775e8a12)