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

  1. Target the write path at the encrypted box's own assets directory (<boxLocalPath>/assets), creating it if missing
  2. Check IsEncryptedBox(boxID) before choosing the global assets fallback in path-resolution code and skip that fallback for encrypted boxes
  3. 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

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


AI-assisted analysis of siyuan-note/siyuan@8641553a1f (2026-09-11). Data as JSON: /api/errors/76c05a56402cac00. Report an issue: GitHub.