siyuan-note/siyuan · error
archive entry resolves outside destination
Error message
archive entry resolves outside destination [%s]
What it means
authorizeArchiveEntry validates each zip member's final output path against the destination directory before extraction. Beyond the lexical check, it resolves the longest existing parent of both the entry path and the destination (following symlinks on disk) and re-checks containment. This error is thrown when the entry path, after resolving symlinks of existing directories, escapes the destination directory.
Solutions
- Inspect the archive entry names and remove/fix any entries whose paths traverse through symlinked directories (e.g. 'link/../../etc/passwd').
- Remove or re-point any symlinks inside the destination directory that resolve outside it before extracting.
- Choose a destPath that is a plain, non-symlinked directory inside the workspace.
- If the archive is trusted, extract it manually outside the MCP tool and verify contents first.
Example fix
// before (member path 'link/secret.txt' where link -> /etc) authorizeArchiveEntry(destAbs, filepath.Join(destAbs, "link/secret.txt"), "link/secret.txt") // -> resolves outside destination // after (recreate archive with members that stay inside the destination) zip -r safe.zip ./docs // entries like docs/file.md resolve inside destPath
Defensive patterns
Strategy: validation
Validate before calling
const members = await listZipEntries(zipPath);
const bad = members.find(n => !isInsideDest(n, destPath));
if (bad) throw new Error(`entry escapes destination: ${bad}`);
function isInsideDest(name, dest) {
const joined = path.resolve(dest, name.replaceAll("\\", "/"));
return joined.startsWith(path.resolve(dest) + path.sep);
} Type guard
function entryStaysInsideDest(entryName, destAbs) {
const rel = path.relative(path.resolve(destAbs), path.resolve(destAbs, entryName));
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
} Prevention
- Reject archives containing entries with '..' segments or absolute paths before extraction.
- Never extract into a directory containing symlinks pointing outside the workspace.
- Keep destPath a plain workspace-relative directory, not a symlinked path.
- Inspect archive listings (unzip -l) before extracting untrusted archives.
When it happens
Trigger: Calling the unzip MCP tool (or extractGuardedArchive) with an archive whose member, when joined to destPath, points through an existing symlinked directory whose resolved target lies outside the destination directory. The lexical filepath.Rel check passes but the symlink-resolved containment check fails.
Common situations: A malicious archive combined with a pre-planted symlink inside the destination tree (or the destination itself being under a symlink pointing outside, e.g. into the workspace root or system directories); also occurs when destPath itself resolves outside the expected tree so resolved paths diverge.
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
- asset path resolves outside assets directory
- child template path is outside the current template package
- marketplace package contains an invalid path
- marketplace package contains an unsupported file
- notebook asset path resolves outside notebook directory
AI-assisted analysis of siyuan-note/siyuan@9f775e8a12 (2026-09-19).
Data as JSON: /api/errors/221940e7c76a0a07.
Report an issue: GitHub.
Appendix: source
Thrown at kernel/mcp/tools/unzip.go:152
}
if err = extractArchiveEntry(entry, members[i].path); err != nil {
return err
}
}
return nil
}
// authorizeArchiveEntry 校验归档成员的最终输出路径:必须位于目标目录内(含符号链接解析后),
// 且通过最终路径授权(工作区包含、加密笔记本、symlink 逃逸、敏感文件黑名单)。
func authorizeArchiveEntry(destAbs, entryAbs, display string) error {
rel, err := filepath.Rel(destAbs, entryAbs)
if err != nil || !filepath.IsLocal(rel) {
return fmt.Errorf("archive entry escapes destination [%s]", display)
}
resolved := util.ResolveLongestExistingParent(entryAbs)
resolvedDest := util.ResolveLongestExistingParent(destAbs)
if rel, err = filepath.Rel(resolvedDest, resolved); err != nil || !filepath.IsLocal(rel) {
return fmt.Errorf("archive entry resolves outside destination [%s]", display)
}
return authorizePath(entryAbs, display)
}
func extractArchiveEntry(entry *zip.File, destination string) error {
if entry.FileInfo().IsDir() {
return os.MkdirAll(destination, 0755)
}
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
return err
}
source, err := entry.Open()
if err != nil {
return err
}
defer source.Close()
target, err := os.Create(destination)
if err != nil {View on GitHub (pinned to 9f775e8a12)