Skip to content

Commit 88ac994

Browse files
sethjuarezCopilot
andcommitted
fix(security): restrict file reference resolution to allowed roots
Prevent arbitrary file reads through references by resolving targets canonically and rejecting absolute paths, traversal, and symlink escapes outside the prompt directory unless the host explicitly provides additional allowed file roots. Addresses GHSA-wxhm-2mq7-7697. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 90171a7 commit 88ac994

47 files changed

Lines changed: 2263 additions & 1478 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ Jinja2 (`{{variable}}`, `{% if %}`, `{% for %}`) or Mustache (`{{variable}}`, `{
221221
|--------|---------|
222222
| `${env:VAR}` | Environment variable (required) |
223223
| `${env:VAR:default}` | With fallback value |
224-
| `${file:path.json}` | Load file content |
224+
| `${file:path.json}` | Load file content from the prompt directory tree |
225+
226+
`${file:...}` references are scoped to the containing `.prompty` file's directory by default. Host applications can opt into additional allowed roots through runtime load options; prompts cannot grant themselves broader filesystem access.
225227

226228
### Legacy format
227229

runtime/csharp/Prompty.Anthropic/Prompty.Anthropic.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<TargetFramework>net9.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7-
<Version>2.0.0-beta.1</Version>
7+
<Version>2.0.0-beta.2</Version>
88
<PackageId>Prompty.Anthropic</PackageId>
99
<Authors>Microsoft</Authors>
1010
<Description>Anthropic provider for Prompty — executor and processor for Claude models via the Anthropic Messages API.</Description>

runtime/csharp/Prompty.Core.Tests/LoaderTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,104 @@ public void Load_FileRef_ResolvesJsonFile()
250250
Assert.Equal("shared-key-12345", apiKey.ApiKey);
251251
}
252252

253+
[Fact]
254+
public void Load_FileRef_TraversalOutsidePromptDir_Throws()
255+
{
256+
var root = Directory.CreateTempSubdirectory("prompty-loader-");
257+
try
258+
{
259+
var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
260+
File.WriteAllText(Path.Combine(root.FullName, "secret.txt"), "secret");
261+
var prompt = Path.Combine(promptDir.FullName, "bad.prompty");
262+
File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:../secret.txt}\"\n---\nHello\n");
263+
264+
var ex = Assert.Throws<InvalidOperationException>(() => PromptyLoader.Load(prompt));
265+
Assert.Contains("outside allowed roots", ex.Message);
266+
}
267+
finally
268+
{
269+
root.Delete(recursive: true);
270+
}
271+
}
272+
273+
[Fact]
274+
public void Load_FileRef_AbsolutePathOutsidePromptDir_Throws()
275+
{
276+
var root = Directory.CreateTempSubdirectory("prompty-loader-");
277+
try
278+
{
279+
var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
280+
var secret = Path.Combine(root.FullName, "secret.txt");
281+
File.WriteAllText(secret, "secret");
282+
var prompt = Path.Combine(promptDir.FullName, "bad.prompty");
283+
File.WriteAllText(
284+
prompt,
285+
$"---\nname: bad\ndescription: \"${{file:{secret.Replace("\\", "/")}}}\"\n---\nHello\n");
286+
287+
var ex = Assert.Throws<InvalidOperationException>(() => PromptyLoader.Load(prompt));
288+
Assert.Contains("outside allowed roots", ex.Message);
289+
}
290+
finally
291+
{
292+
root.Delete(recursive: true);
293+
}
294+
}
295+
296+
[Fact]
297+
public void Load_FileRef_AllowedRootPermitsSharedFile()
298+
{
299+
var root = Directory.CreateTempSubdirectory("prompty-loader-");
300+
try
301+
{
302+
var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
303+
var sharedDir = Directory.CreateDirectory(Path.Combine(root.FullName, "shared"));
304+
File.WriteAllText(Path.Combine(sharedDir.FullName, "description.txt"), "shared description");
305+
var prompt = Path.Combine(promptDir.FullName, "shared.prompty");
306+
File.WriteAllText(prompt, "---\nname: shared\ndescription: \"${file:../shared/description.txt}\"\n---\nHello\n");
307+
308+
var agent = PromptyLoader.Load(
309+
prompt,
310+
new PromptyLoadOptions { AllowedFileRoots = [sharedDir.FullName] });
311+
312+
Assert.Equal("shared description", agent.Description);
313+
}
314+
finally
315+
{
316+
root.Delete(recursive: true);
317+
}
318+
}
319+
320+
[Fact]
321+
public void Load_FileRef_SymlinkEscape_Throws()
322+
{
323+
var root = Directory.CreateTempSubdirectory("prompty-loader-");
324+
try
325+
{
326+
var promptDir = Directory.CreateDirectory(Path.Combine(root.FullName, "prompts"));
327+
var secret = Path.Combine(root.FullName, "secret.txt");
328+
File.WriteAllText(secret, "secret");
329+
var link = Path.Combine(promptDir.FullName, "secret-link.txt");
330+
try
331+
{
332+
File.CreateSymbolicLink(link, secret);
333+
}
334+
catch
335+
{
336+
return;
337+
}
338+
339+
var prompt = Path.Combine(promptDir.FullName, "bad.prompty");
340+
File.WriteAllText(prompt, "---\nname: bad\ndescription: \"${file:secret-link.txt}\"\n---\nHello\n");
341+
342+
var ex = Assert.Throws<InvalidOperationException>(() => PromptyLoader.Load(prompt));
343+
Assert.Contains("outside allowed roots", ex.Message);
344+
}
345+
finally
346+
{
347+
root.Delete(recursive: true);
348+
}
349+
}
350+
253351
// --- Tools ---
254352

255353
[Fact]

runtime/csharp/Prompty.Core.Tests/SpecVectorTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ public void LoadVectors_AllPass()
254254

255255
var ctx = new LoadContext
256256
{
257-
PreProcess = d => ReferenceResolver.ResolveReferences(d, "."),
257+
PreProcess = d => ReferenceResolver.ResolveReferences(d, ".", [Path.GetFullPath(".")]),
258258
};
259259

260260
var agent = Prompty.Load(data, ctx);

runtime/csharp/Prompty.Core/Prompty.Core.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<TargetFramework>net9.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7-
<Version>2.0.0-beta.1</Version>
7+
<Version>2.0.0-beta.2</Version>
88
<PackageId>Prompty.Core</PackageId>
99
<Authors>Microsoft</Authors>
1010
<Description>Prompty is an asset class and format for LLM prompts. Core library with loader, pipeline, renderers, parsers, and tracing.</Description>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
namespace Prompty.Core;
4+
5+
/// <summary>
6+
/// Options for loading .prompty files.
7+
/// </summary>
8+
public sealed class PromptyLoadOptions
9+
{
10+
/// <summary>
11+
/// Additional directories that ${file:...} references may read from.
12+
/// The prompt file's directory is always allowed.
13+
/// </summary>
14+
public IEnumerable<string> AllowedFileRoots { get; init; } = [];
15+
}

runtime/csharp/Prompty.Core/PromptyLoader.cs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,18 @@ public static class PromptyLoader
1212
/// Load a .prompty file and return a fully typed Prompty instance.
1313
/// </summary>
1414
/// <param name="path">Path to the .prompty file (absolute or relative to cwd).</param>
15+
/// <param name="options">Optional load behavior, including additional allowed file roots.</param>
1516
/// <returns>A loaded Prompty instance with instructions from the markdown body.</returns>
1617
/// <exception cref="FileNotFoundException">If the file does not exist.</exception>
1718
/// <exception cref="InvalidOperationException">If frontmatter is invalid or env vars are missing.</exception>
18-
public static Prompty Load(string path)
19+
public static Prompty Load(string path, PromptyLoadOptions? options = null)
1920
{
2021
var fullPath = Path.GetFullPath(path);
2122
if (!File.Exists(fullPath))
2223
throw new FileNotFoundException($"Prompty file not found: '{fullPath}'", fullPath);
2324

2425
var contents = File.ReadAllText(fullPath);
25-
return Build(contents, fullPath);
26+
return Build(contents, fullPath, options);
2627
}
2728

2829
/// <summary>
@@ -32,27 +33,42 @@ public static Prompty Load(string path)
3233
/// <param name="cancellationToken">Optional cancellation token.</param>
3334
/// <returns>A loaded Prompty instance with instructions from the markdown body.</returns>
3435
public static async Task<Prompty> LoadAsync(string path, CancellationToken cancellationToken = default)
36+
{
37+
return await LoadAsync(path, options: null, cancellationToken);
38+
}
39+
40+
/// <summary>
41+
/// Asynchronously load a .prompty file with explicit load options.
42+
/// </summary>
43+
/// <param name="path">Path to the .prompty file (absolute or relative to cwd).</param>
44+
/// <param name="options">Optional load behavior, including additional allowed file roots.</param>
45+
/// <param name="cancellationToken">Optional cancellation token.</param>
46+
/// <returns>A loaded Prompty instance with instructions from the markdown body.</returns>
47+
public static async Task<Prompty> LoadAsync(
48+
string path,
49+
PromptyLoadOptions? options,
50+
CancellationToken cancellationToken = default)
3551
{
3652
var fullPath = Path.GetFullPath(path);
3753
if (!File.Exists(fullPath))
3854
throw new FileNotFoundException($"Prompty file not found: '{fullPath}'", fullPath);
3955

4056
var contents = await File.ReadAllTextAsync(fullPath, cancellationToken);
41-
return Build(contents, fullPath);
57+
return Build(contents, fullPath, options);
4258
}
4359

4460
/// <summary>
4561
/// Core loading logic shared by sync and async paths.
4662
/// </summary>
47-
private static Prompty Build(string contents, string fullPath)
63+
private static Prompty Build(string contents, string fullPath, PromptyLoadOptions? options)
4864
{
4965
// 1. Split frontmatter + body
5066
var data = FrontmatterParser.Parse(contents);
5167

5268
// 2. Load via typed model with ${env:}/${file:} resolution
5369
var ctx = new LoadContext
5470
{
55-
PreProcess = ReferenceResolver.CreatePreProcess(fullPath),
71+
PreProcess = ReferenceResolver.CreatePreProcess(fullPath, options?.AllowedFileRoots),
5672
};
5773

5874
var agent = Prompty.Load(data, ctx);

runtime/csharp/Prompty.Core/ReferenceResolver.cs

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,31 @@ public static class ReferenceResolver
1717
/// References are resolved relative to the .prompty file's parent directory.
1818
/// </summary>
1919
/// <param name="promptyFilePath">Absolute path to the .prompty file (for resolving relative ${file:} refs).</param>
20+
/// <param name="allowedFileRoots">Additional directories that ${file:} references may read from.</param>
2021
/// <returns>A callback suitable for LoadContext.PreProcess.</returns>
21-
public static Func<Dictionary<string, object?>, Dictionary<string, object?>> CreatePreProcess(string promptyFilePath)
22+
public static Func<Dictionary<string, object?>, Dictionary<string, object?>> CreatePreProcess(
23+
string promptyFilePath,
24+
IEnumerable<string>? allowedFileRoots = null)
2225
{
23-
var parentDir = Path.GetDirectoryName(Path.GetFullPath(promptyFilePath))
26+
var parentDir = Path.GetDirectoryName(GetCanonicalPath(promptyFilePath))
2427
?? throw new ArgumentException($"Cannot determine parent directory of '{promptyFilePath}'");
28+
var allowedRoots = new[] { parentDir }
29+
.Concat(allowedFileRoots ?? [])
30+
.Select(GetCanonicalPath)
31+
.ToArray();
2532

26-
return data => ResolveReferences(data, parentDir);
33+
return data => ResolveReferences(data, parentDir, allowedRoots);
2734
}
2835

2936
/// <summary>
3037
/// Walks a dictionary and resolves any string values matching ${protocol:value} patterns.
3138
/// Only processes top-level string values in the given dictionary (recursive walking is
3239
/// handled by LoadContext calling PreProcess on each nested dict).
3340
/// </summary>
34-
internal static Dictionary<string, object?> ResolveReferences(Dictionary<string, object?> data, string parentDir)
41+
internal static Dictionary<string, object?> ResolveReferences(
42+
Dictionary<string, object?> data,
43+
string parentDir,
44+
IReadOnlyCollection<string> allowedRoots)
3545
{
3646
foreach (var key in data.Keys.ToList())
3747
{
@@ -55,7 +65,7 @@ public static class ReferenceResolver
5565
data[key] = ResolveEnvVar(remainder, key);
5666
break;
5767
case "file":
58-
data[key] = ResolveFileRef(remainder, parentDir, key);
68+
data[key] = ResolveFileRef(remainder, parentDir, allowedRoots, key);
5969
break;
6070
// Unknown protocol: leave unchanged per spec §4
6171
}
@@ -100,13 +110,26 @@ private static object ResolveEnvVar(string remainder, string key)
100110
/// Resolves ${file:relative/path}. Loads content based on file extension:
101111
/// .json → parsed as JSON object, .yaml/.yml → parsed as YAML object, else → raw text.
102112
/// </summary>
103-
private static object ResolveFileRef(string relativePath, string parentDir, string key)
113+
private static object ResolveFileRef(
114+
string relativePath,
115+
string parentDir,
116+
IReadOnlyCollection<string> allowedRoots,
117+
string key)
104118
{
105-
var fullPath = Path.GetFullPath(Path.Combine(parentDir, relativePath));
119+
var fullPath = Path.IsPathRooted(relativePath)
120+
? Path.GetFullPath(relativePath)
121+
: Path.GetFullPath(Path.Combine(parentDir, relativePath));
106122
if (!File.Exists(fullPath))
107123
throw new FileNotFoundException(
108124
$"Referenced file '{relativePath}' not found (resolved to '{fullPath}', key: '{key}').");
109125

126+
fullPath = GetCanonicalPath(fullPath);
127+
if (!allowedRoots.Any(root => IsWithinRoot(fullPath, root)))
128+
{
129+
throw new InvalidOperationException(
130+
$"File reference '{relativePath}' resolves outside allowed roots (resolved to '{fullPath}', key: '{key}').");
131+
}
132+
110133
var content = File.ReadAllText(fullPath);
111134
var ext = Path.GetExtension(fullPath).ToLowerInvariant();
112135

@@ -136,4 +159,30 @@ private static object LoadYamlAsDict(string content)
136159
var result = YamlUtils.Deserializer.Deserialize<Dictionary<string, object?>>(content);
137160
return result ?? new Dictionary<string, object?>();
138161
}
162+
163+
private static bool IsWithinRoot(string path, string root)
164+
{
165+
var relative = Path.GetRelativePath(root, path);
166+
return relative == "."
167+
|| (!relative.StartsWith("..", StringComparison.Ordinal)
168+
&& !Path.IsPathRooted(relative));
169+
}
170+
171+
private static string GetCanonicalPath(string path)
172+
{
173+
var fullPath = Path.GetFullPath(path);
174+
if (File.Exists(fullPath))
175+
{
176+
var file = new FileInfo(fullPath);
177+
var target = file.ResolveLinkTarget(returnFinalTarget: true);
178+
return Path.GetFullPath(target?.FullName ?? file.FullName);
179+
}
180+
if (Directory.Exists(fullPath))
181+
{
182+
var directory = new DirectoryInfo(fullPath);
183+
var target = directory.ResolveLinkTarget(returnFinalTarget: true);
184+
return Path.GetFullPath(target?.FullName ?? directory.FullName);
185+
}
186+
return fullPath;
187+
}
139188
}

runtime/csharp/Prompty.Foundry/Prompty.Foundry.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Nullable>enable</Nullable>
77
<NoWarn>OPENAI001;CS1591</NoWarn>
88
<GenerateDocumentationFile>true</GenerateDocumentationFile>
9-
<Version>2.0.0-beta.1</Version>
9+
<Version>2.0.0-beta.2</Version>
1010
<PackageId>Prompty.Foundry</PackageId>
1111
<Authors>Microsoft</Authors>
1212
<Description>Microsoft Foundry (Azure OpenAI) provider for Prompty — executor and processor for Azure-hosted models.</Description>

runtime/csharp/Prompty.OpenAI/Prompty.OpenAI.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<Nullable>enable</Nullable>
77
<NoWarn>OPENAI001;CS1591</NoWarn>
88
<GenerateDocumentationFile>true</GenerateDocumentationFile>
9-
<Version>2.0.0-beta.1</Version>
9+
<Version>2.0.0-beta.2</Version>
1010
<PackageId>Prompty.OpenAI</PackageId>
1111
<Authors>Microsoft</Authors>
1212
<Description>OpenAI provider for Prompty — executor and processor for OpenAI chat, embedding, image, and agent APIs.</Description>

0 commit comments

Comments
 (0)