{"record":{"id":"ae24628990dbc620","repo":"siyuan-note/siyuan","slug":"archive-entry-escapes-destination-s","errorCode":null,"errorMessage":"archive entry escapes destination [%s]","messagePattern":"archive entry escapes destination \\[(.+?)\\]","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"kernel/mcp/tools/unzip.go","lineNumber":147,"sourceCode":"\t}\n\tfor i, entry := range reader.File {\n\t\t// 解压前再次授权，不复用预检阶段的判定结果\n\t\tif err = authorizeArchiveEntry(destAbs, members[i].path, members[i].name); err != nil {\n\t\t\treturn err\n\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 {","sourceCodeStart":129,"sourceCodeEnd":165,"githubUrl":"https://github.com/siyuan-note/siyuan/blob/9f775e8a12daef8255556097396f9b2739078892/kernel/mcp/tools/unzip.go#L129-L165","documentation":"authorizeArchiveEntry re-checks each extracted member's final output path after joining with the destination: filepath.Rel from destAbs must be local. If an entry's joined path escapes the destination directory (or Rel fails) it returns this error. This is the first of two containment checks — a second check re-verifies after symlink resolution.","triggerScenarios":"extractGuardedArchive producing a member whose path (destAbs + name) lands outside destAbs — e.g. a name that survived earlier checks via unusual components, a destination path that itself changed, or platform quirks where the joined path normalizes outside the destination.","commonSituations":"Archives with names that pass the per-entry IsLocal check but combine with an unexpected destination (e.g. destination containing symlinked parents); crafted names exploiting separator normalization differences; calling unzip with a destination argument computed dynamically.","solutions":["Verify the destination directory passed to the unzip tool is a plain, real (non-symlink) directory inside the workspace","Re-list the archive entries and remove any with .., absolute, or otherwise anomalous names, then repack","If you need files elsewhere, extract into the intended directory directly instead of relying on entry paths to climb out"],"exampleFix":"// before (dest itself is a symlink)\ndest = \"/workspace/data/link-to-elsewhere\"\nunzipHandler({\"path\": \"a.zip\", \"dest\": dest}) // entry Rel escapes\n// after\ndest = \"/workspace/data/extracted\" // real directory\nos.MkdirAll(dest, 0755)\nunzipHandler({\"path\": \"a.zip\", \"dest\": dest})","handlingStrategy":"validation","validationCode":"const destAbs = fs.realpathSync(path.resolve(dest));\nif (!destAbs.startsWith(WORKSPACE_DIR + path.sep)) {\n  throw new Error('destination must be a real directory inside the workspace');\n}","typeGuard":"function isSafeDest(dest, workspace) {\n  try {\n    const real = fs.realpathSync(path.resolve(dest));\n    return real.startsWith(path.resolve(workspace) + path.sep) && fs.statSync(real).isDirectory();\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  await callMcpTool('unzip', { path: zipPath, dest });\n} catch (e) {\n  if (e.message.startsWith('archive entry escapes destination')) {\n    // recreate the destination as a plain directory and re-list the archive for bad entries\n  }\n}","preventionTips":["Use a freshly created, non-symlink destination directory for extraction","Pre-scan entry names and reject anything with .. or absolute paths","Never reuse symlinked directories as extraction targets"],"tags":["security","archive","zip-slip","path-validation"],"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"}