siyuan-note/siyuan · error
encrypted box asset must be written inside the box directory
Error message
encrypted box asset must be written inside the box directory, got global path: %s
What it means
writeAssetFile refuses to write assets for an encrypted notebook to a path outside that notebook's directory (e.g. the global data/assets folder). Encrypted content must stay inside the box directory so it is encrypted at rest; writing to the global assets path would leak plaintext (or lose the encryption envelope).
Source
Thrown at kernel/model/upload.go:567
}
}
return
}
// writeAssetFile 把 src 的内容写入 writePath。从 writePath 反查真实 boxID 决定是否加密——
// 不轻信传入的 boxID(调用方可能未传,或 assetsDirPath 指向加密笔记本但 id 为空)。
// 加密笔记本必须已解锁(DEK 在内存)才写入;加密但未解锁返回错误(fail-closed,避免明文落盘)。
// 非加密笔记本按 reader 直接写(走 filelock.WriteFileByReader 原路径,保留锁语义)。
func writeAssetFile(writePath string, src io.Reader, boxID, originalName string) (err error) {
// 从 writePath 反查真实 boxID,与传入 boxID 交叉校验
pathBoxID := ExtractBoxIDFromAssetsPath(writePath)
// 传入 boxID 与路径 box 都非空但不一致:路径指向另一个 box,拒绝(防跨 box 写入)
if boxID != "" && pathBoxID != "" && boxID != pathBoxID {
return fmt.Errorf("boxID mismatch: param=%s, path=%s", boxID, pathBoxID)
}
// 路径不在 box 下但传入的是加密 box:加密内容只能写 box 内,拒绝写全局 assets
if pathBoxID == "" && boxID != "" && IsEncryptedBox(boxID) {
return fmt.Errorf("encrypted box asset must be written inside the box directory, got global path: %s", writePath)
}
actualBoxID := pathBoxID
if actualBoxID == "" {
actualBoxID = boxID // 路径不在 box 下(如全局 assets),回退传入值
}
if actualBoxID != "" && IsEncryptedBox(actualBoxID) {
HoldBoxReadLock(actualBoxID)
defer ReleaseBoxReadLock(actualBoxID)
dek, dekErr := GetDEKIfUnlocked(actualBoxID)
if dekErr != nil {
// 加密笔记本未解锁:拒绝写入,避免明文落盘(深度防御,见 issue #18034)
return dekErr
}
// 已解锁的加密 box:全读 → 加密 → 落盘
raw, readErr := io.ReadAll(src)
if readErr != nil {
return readErr
}View on GitHub (pinned to 8641553a1f)
Solutions
- Target the write path at the encrypted box's own assets directory (<boxLocalPath>/assets), creating it if missing
- Check IsEncryptedBox(boxID) before choosing the global assets fallback in path-resolution code and skip that fallback for encrypted boxes
- If the write is meant for global assets, explicitly pass an empty boxID only when the content is intended to be global (not from an encrypted notebook)
Example fix
// before assetsDir := filepath.Join(util.DataDir, "assets") err := model.writeAssetFile(filepath.Join(assetsDir, name), src, boxID, name) // after assetsDir := filepath.Join(model.GetBoxLocalPath(boxID), "assets") os.MkdirAll(assetsDir, 0755) err := model.writeAssetFile(filepath.Join(assetsDir, name), src, boxID, name)
Defensive patterns
Strategy: validation
Validate before calling
func assertEncryptedBoxAssetPath(boxID, writePath string) error {
if boxID != "" && model.IsEncryptedBox(boxID) && model.ExtractBoxIDFromAssetsPath(writePath) == "" {
return fmt.Errorf("encrypted box %s asset must go inside the box dir, got %s", boxID, writePath)
}
return nil
} Type guard
func isGlobalPathForEncryptedBox(boxID, writePath string) bool {
return boxID != "" && model.IsEncryptedBox(boxID) && model.ExtractBoxIDFromAssetsPath(writePath) == ""
} Try / catch
if err := model.writeAssetFile(writePath, src, boxID, name); err != nil {
if strings.HasPrefix(err.Error(), "encrypted box asset must be written inside") {
// relocate the write under the encrypted box's assets dir
writePath = filepath.Join(model.GetBoxLocalPath(boxID), "assets", name)
err = model.writeAssetFile(writePath, src, boxID, name)
}
return err
} Prevention
- Never use the global data/assets fallback when the target notebook is encrypted — check IsEncryptedBox first in path resolution
- Always resolve asset directories with getAssetsDir (which enforces box-local paths for encrypted boxes) rather than building paths manually
- Require callers to pass the target document or box ID so encrypted-asset uploads cannot fall back to global paths
When it happens
Trigger: Calling writeAssetFile with boxID of an encrypted notebook and a writePath that ExtractBoxIDFromAssetsPath resolves to empty (a global path such as data/assets), via Upload, InsertAssetBytes, insertLocalAssets, netAssets2LocalAssets0, or importSYAssets.
Common situations: An API/plugin caller uploads an asset without specifying a target document, so getAssetsDir falls back to the global data/assets directory; automatic asset insertion into the default global assets folder for notebooks that are encrypted.
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
- boxID mismatch: param=%s, path=%s
- symlink resolves into encrypted notebook [%s]
- load tree failed: %s
- master password migration is pending
- Move notebook [%s] file [%s] failed: %s
AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11).
Data as JSON: /api/errors/76c05a56402cac00.
Report an issue: GitHub.