diff --git a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj index cff64b5b0..24346c1cf 100644 --- a/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj +++ b/runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - 2.0.0-beta.3 + 2.0.0-beta.4 Prompty.Anthropic Microsoft Anthropic provider for Prompty — executor and processor for Claude models via the Anthropic Messages API. diff --git a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs index 8eaa3bd3a..f874eab7f 100644 --- a/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs +++ b/runtime/csharp/Prompty.Core.Tests/LoaderTests.cs @@ -348,6 +348,52 @@ public void Load_FileRef_SymlinkEscape_Throws() } } + [Fact] + public void Load_FileRef_IntermediateDirectorySymlinkEscape_Throws() + { + var root = Directory.CreateTempSubdirectory("prompty-loader-"); + try + { + var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts")); + var externalDir = Directory.CreateDirectory(Path.Combine(root.FullName, "external")); + File.WriteAllText(Path.Combine(externalDir.FullName, "secret.txt"), "secret"); + Directory.CreateSymbolicLink(Path.Combine(promptDir.FullName, "assets"), externalDir.FullName); + + var prompt = Path.Combine(promptDir.FullName, "bad.prompty"); + File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:assets/secret.txt}\"\n---\nHello\n"); + + var ex = Assert.Throws(() => PromptyLoader.Load(prompt)); + Assert.Contains("outside allowed roots", ex.Message); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public async Task LoadAsync_FileRef_IntermediateDirectorySymlinkEscape_Throws() + { + var root = Directory.CreateTempSubdirectory("prompty-loader-"); + try + { + var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts")); + var externalDir = Directory.CreateDirectory(Path.Combine(root.FullName, "external")); + File.WriteAllText(Path.Combine(externalDir.FullName, "secret.txt"), "secret"); + Directory.CreateSymbolicLink(Path.Combine(promptDir.FullName, "assets"), externalDir.FullName); + + var prompt = Path.Combine(promptDir.FullName, "bad.prompty"); + File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:assets/secret.txt}\"\n---\nHello\n"); + + var ex = await Assert.ThrowsAsync(() => PromptyLoader.LoadAsync(prompt)); + Assert.Contains("outside allowed roots", ex.Message); + } + finally + { + root.Delete(recursive: true); + } + } + // --- Tools --- [Fact] diff --git a/runtime/csharp/Prompty.Core/Prompty.Core.csproj b/runtime/csharp/Prompty.Core/Prompty.Core.csproj index 0298cb923..151ca0d14 100644 --- a/runtime/csharp/Prompty.Core/Prompty.Core.csproj +++ b/runtime/csharp/Prompty.Core/Prompty.Core.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - 2.0.0-beta.3 + 2.0.0-beta.4 Prompty.Core Microsoft Prompty is an asset class and format for LLM prompts. Core library with loader, pipeline, renderers, parsers, and tracing. diff --git a/runtime/csharp/Prompty.Core/ReferenceResolver.cs b/runtime/csharp/Prompty.Core/ReferenceResolver.cs index 1f613d2c1..3e765f42d 100644 --- a/runtime/csharp/Prompty.Core/ReferenceResolver.cs +++ b/runtime/csharp/Prompty.Core/ReferenceResolver.cs @@ -171,18 +171,42 @@ private static bool IsWithinRoot(string path, string root) private static string GetCanonicalPath(string path) { var fullPath = Path.GetFullPath(path); - if (File.Exists(fullPath)) + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root)) { - var file = new FileInfo(fullPath); - var target = file.ResolveLinkTarget(returnFinalTarget: true); - return Path.GetFullPath(target?.FullName ?? file.FullName); + return fullPath; } - if (Directory.Exists(fullPath)) + + var canonicalPath = root; + var components = fullPath[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + + foreach (var component in components) + { + canonicalPath = Path.Combine(canonicalPath, component); + var target = ResolveLinkTarget(canonicalPath); + if (target is not null) + { + canonicalPath = Path.GetFullPath(target.FullName); + } + } + + return canonicalPath; + } + + private static FileSystemInfo? ResolveLinkTarget(string path) + { + if (Directory.Exists(path)) { - var directory = new DirectoryInfo(fullPath); - var target = directory.ResolveLinkTarget(returnFinalTarget: true); - return Path.GetFullPath(target?.FullName ?? directory.FullName); + return new DirectoryInfo(path).ResolveLinkTarget(returnFinalTarget: true); } - return fullPath; + + if (File.Exists(path)) + { + return new FileInfo(path).ResolveLinkTarget(returnFinalTarget: true); + } + + return null; } } diff --git a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj index d67aae80a..ec3d0ba7f 100644 --- a/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj +++ b/runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj @@ -6,7 +6,7 @@ enable OPENAI001;CS1591 true - 2.0.0-beta.3 + 2.0.0-beta.4 Prompty.Foundry Microsoft Microsoft Foundry (Azure OpenAI) provider for Prompty — executor and processor for Azure-hosted models. diff --git a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj index 91968fb23..6c8c5d436 100644 --- a/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj +++ b/runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj @@ -6,7 +6,7 @@ enable OPENAI001;CS1591 true - 2.0.0-beta.3 + 2.0.0-beta.4 Prompty.OpenAI Microsoft OpenAI provider for Prompty — executor and processor for OpenAI chat, embedding, image, and agent APIs.