{"record":{"id":"043f62102975999d","repo":"microsoft/aspire","slug":"aspire-skills-archive-entry-0-escapes-the-extraction","errorCode":null,"errorMessage":"Aspire skills archive entry '{0}' escapes the extraction directory.","messagePattern":"Aspire skills archive entry '(.+?)' escapes the extraction directory\\.","errorType":"exception","errorClass":"InvalidDataException","httpStatus":null,"severity":"error","filePath":"src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundleProvider.cs","lineNumber":569,"sourceCode":"        }\n    }\n\n    private static string GetSafeArchiveDestinationPath(string destinationRoot, string entryName)\n    {\n        var normalizedEntryName = entryName.Replace('\\\\', '/');\n        var segments = normalizedEntryName.Split('/', StringSplitOptions.RemoveEmptyEntries);\n        if (Path.IsPathRooted(normalizedEntryName) ||\n            segments.Length == 0 ||\n            segments.Any(static segment => !IsPortablePathSegment(segment)))\n        {\n            throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, \"Aspire skills archive entry '{0}' is not safe.\", entryName));\n        }\n\n        var destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryName.Replace('/', Path.DirectorySeparatorChar)));\n        if (!destinationPath.StartsWith(destinationRoot + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&\n            !string.Equals(destinationPath, destinationRoot, StringComparison.Ordinal))\n        {\n            throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, \"Aspire skills archive entry '{0}' escapes the extraction directory.\", entryName));\n        }\n\n        return destinationPath;\n    }\n\n    private static DirectoryInfo FindBundleRoot(string extractionDirectory)\n    {\n        var rootManifestPath = Path.Combine(extractionDirectory, ManifestFileName);\n        if (File.Exists(rootManifestPath))\n        {\n            return new DirectoryInfo(extractionDirectory);\n        }\n\n        var packageDirectory = Path.Combine(extractionDirectory, \"package\");\n        var packageManifestPath = Path.Combine(packageDirectory, ManifestFileName);\n        if (File.Exists(packageManifestPath))\n        {\n            return new DirectoryInfo(packageDirectory);","sourceCodeStart":551,"sourceCodeEnd":587,"githubUrl":"https://github.com/microsoft/aspire/blob/25830f84bd145686607ad00c057b3f84e2e51d43/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundleProvider.cs#L551-L587","documentation":"Even after segment validation, GetSafeArchiveDestinationPath performs a final containment check: the fully-resolved destination must remain under the extraction root. If Path.GetFullPath resolves outside destinationRoot (e.g. via a link or unexpected normalization), this InvalidDataException is thrown as the last line of defense against archive escape.","triggerScenarios":"An archive entry whose combined path escapes destinationRoot after full resolution, encountered during ExtractArchive of a skills package.","commonSituations":"Archives abusing symlinks or crafted names to escape the extraction dir; extracting into an unexpected working directory where the root check fails; corrupted archive metadata.","solutions":["Repackage or re-download the archive so entries resolve inside the extraction directory","Ensure the extraction destination directory itself is a real, non-symlinked path","Inspect entry names with 'tar -tf' and remove entries that resolve outside the root"],"exampleFix":"// before (tar entry via symlink)\nlink -> /etc, link/passwd\n// after\nskills/my-skill/SKILL.md","handlingStrategy":"try-catch","validationCode":"var dest = Path.GetFullPath(Path.Combine(root, entry.Name.Replace('/', Path.DirectorySeparatorChar)));\nif (!dest.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.Ordinal))\n    throw new Exception($\"Entry '{entry.Name}' escapes the extraction directory.\");","typeGuard":null,"tryCatchPattern":"try { await provider.CreateAsync(...); }\ncatch (InvalidDataException ex) when (ex.Message.Contains(\"escapes the extraction directory\")) { /* treat archive as malicious; do not extract */ }","preventionTips":["Extract into a freshly created, non-symlinked temp directory","Never extract untrusted archives without a containment check","Re-verify archives against their published SHA-512 before extraction"],"tags":["security","archive","extraction","path-traversal"],"backgroundTag":"path-traversal-blocked","analyzedSha":"25830f84bd145686607ad00c057b3f84e2e51d43","analyzedAt":"2026-09-16T11:10:06.193Z","contentChangedAt":"2026-09-16T11:10:06.193Z","schemaVersion":2},"datasetVersion":"2026-09-21T04:17:39.646Z"}