{"record":{"id":"221940e7c76a0a07","repo":"siyuan-note/siyuan","slug":"archive-entry-resolves-outside-destination-s-unzip","errorCode":null,"errorMessage":"archive entry resolves outside destination [%s]","messagePattern":"archive entry resolves outside destination \\[(.+?)\\]","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/mcp/tools/unzip.go","lineNumber":152,"sourceCode":"\t\t}\n\t\tif err = extractArchiveEntry(entry, members[i].path); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn nil\n}\n\n// authorizeArchiveEntry 校验归档成员的最终输出路径：必须位于目标目录内（含符号链接解析后），\n// 且通过最终路径授权（工作区包含、加密笔记本、symlink 逃逸、敏感文件黑名单）。\nfunc authorizeArchiveEntry(destAbs, entryAbs, display string) error {\n\trel, err := filepath.Rel(destAbs, entryAbs)\n\tif err != nil || !filepath.IsLocal(rel) {\n\t\treturn fmt.Errorf(\"archive entry escapes destination [%s]\", display)\n\t}\n\tresolved := util.ResolveLongestExistingParent(entryAbs)\n\tresolvedDest := util.ResolveLongestExistingParent(destAbs)\n\tif rel, err = filepath.Rel(resolvedDest, resolved); err != nil || !filepath.IsLocal(rel) {\n\t\treturn fmt.Errorf(\"archive entry resolves outside destination [%s]\", display)\n\t}\n\treturn authorizePath(entryAbs, display)\n}\n\nfunc extractArchiveEntry(entry *zip.File, destination string) error {\n\tif entry.FileInfo().IsDir() {\n\t\treturn os.MkdirAll(destination, 0755)\n\t}\n\tif err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {\n\t\treturn err\n\t}\n\tsource, err := entry.Open()\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer source.Close()\n\ttarget, err := os.Create(destination)\n\tif err != nil {","sourceCodeStart":134,"sourceCodeEnd":170,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/9f775e8a12daef8255556097396f9b2739078892/kernel/mcp/tools/unzip.go#L134-L170","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before (member path 'link/secret.txt' where link -> /etc)\nauthorizeArchiveEntry(destAbs, filepath.Join(destAbs, \"link/secret.txt\"), \"link/secret.txt\") // -> resolves outside destination\n// after (recreate archive with members that stay inside the destination)\nzip -r safe.zip ./docs   // entries like docs/file.md resolve inside destPath","handlingStrategy":"validation","validationCode":"const members = await listZipEntries(zipPath);\nconst bad = members.find(n => !isInsideDest(n, destPath));\nif (bad) throw new Error(`entry escapes destination: ${bad}`);\nfunction isInsideDest(name, dest) {\n  const joined = path.resolve(dest, name.replaceAll(\"\\\\\", \"/\"));\n  return joined.startsWith(path.resolve(dest) + path.sep);\n}","typeGuard":"function entryStaysInsideDest(entryName, destAbs) {\n  const rel = path.relative(path.resolve(destAbs), path.resolve(destAbs, entryName));\n  return rel !== \"\" && !rel.startsWith(\"..\") && !path.isAbsolute(rel);\n}","tryCatchPattern":null,"preventionTips":["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."],"tags":["security","path-traversal","zip","symlink"],"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"}