Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Fixed a path traversal vulnerability where workspace consumer identifiers (`clientName`/`pluginName`) and workspace configuration keys were used as filesystem path components without containment validation, allowing a crafted name (e.g. `junk/../Victim`) to overwrite another consumer's cached OpenAPI description. [#7919](https://github.com/microsoft/kiota/issues/7919)

## [1.33.0] - 2026-07-06

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
Expand All @@ -21,7 +22,39 @@ public DescriptionStorageService(string targetDirectory)
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
private string GetDescriptionFilePath(string clientName, string extension) => Path.Combine(TargetDirectory, DescriptionsSubDirectoryRelativePath, clientName, $"openapi.{extension}");
private string GetDescriptionFilePath(string clientName, string extension)
{
ValidateConsumerName(clientName);
ValidateExtension(extension);
var documentsDirectory = Path.Join(TargetDirectory, DescriptionsSubDirectoryRelativePath);
var descriptionFilePath = Path.GetFullPath(Path.Combine(documentsDirectory, clientName, $"openapi.{extension}"));
Comment thread
gavinbarron marked this conversation as resolved.
Dismissed
var documentsFullPath = Path.GetFullPath(documentsDirectory);
var documentsFullPathWithSeparator = Path.EndsInDirectorySeparator(documentsFullPath) ? documentsFullPath : documentsFullPath + Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (!descriptionFilePath.StartsWith(documentsFullPathWithSeparator, comparison))
throw new InvalidOperationException($"The consumer name '{clientName}' resolves to a path outside of the documents directory.");
return descriptionFilePath;
}
internal static void ValidateConsumerName(string clientName)
{
if (string.IsNullOrWhiteSpace(clientName))
throw new InvalidOperationException("The consumer name must not be empty or whitespace.");
if (Path.IsPathRooted(clientName) ||
clientName.Contains('/', StringComparison.Ordinal) ||
clientName.Contains('\\', StringComparison.Ordinal) ||
clientName.Split('/', '\\').Contains("..", StringComparer.Ordinal) ||
clientName is "." or "..")
throw new InvalidOperationException($"The consumer name '{clientName}' is not a valid single path segment and cannot navigate the file system.");
}
private static void ValidateExtension(string extension)
{
if (string.IsNullOrWhiteSpace(extension))
throw new InvalidOperationException("The description file extension must not be empty or whitespace.");
if (Path.IsPathRooted(extension) ||
extension.Contains('/', StringComparison.Ordinal) ||
extension.Contains('\\', StringComparison.Ordinal))
throw new InvalidOperationException($"The description file extension '{extension}' must not contain path separators or be rooted.");
}
public async Task UpdateDescriptionAsync(string clientName, Stream description, string extension = "yml", CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(clientName);
Expand Down Expand Up @@ -62,7 +95,7 @@ public void RemoveDescription(string clientName, string extension = "yml")
}
public void Clean()
{
var kiotaDirectoryPath = Path.Combine(TargetDirectory, DescriptionsSubDirectoryRelativePath);
var kiotaDirectoryPath = Path.Join(TargetDirectory, DescriptionsSubDirectoryRelativePath);
if (Path.Exists(kiotaDirectoryPath))
Directory.Delete(kiotaDirectoryPath, true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,22 @@ private void ValidateConsumerOutputPaths(WorkspaceConfiguration? configuration)
foreach (var client in configuration.Clients)
try
{
DescriptionStorageService.ValidateConsumerName(client.Key);
BaseApiConsumerConfiguration.ValidateOutputPath(client.Value.OutputPath, workspaceDirectory);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"The client {client.Key} has an invalid output path: {ex.Message}", ex);
throw new InvalidOperationException($"The client {client.Key} has an invalid configuration: {ex.Message}", ex);
}
foreach (var plugin in configuration.Plugins)
try
{
DescriptionStorageService.ValidateConsumerName(plugin.Key);
BaseApiConsumerConfiguration.ValidateOutputPath(plugin.Value.OutputPath, workspaceDirectory);
}
catch (InvalidOperationException ex)
{
throw new InvalidOperationException($"The plugin {plugin.Key} has an invalid output path: {ex.Message}", ex);
throw new InvalidOperationException($"The plugin {plugin.Key} has an invalid configuration: {ex.Message}", ex);
}
}
public async Task BackupConfigAsync(CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public WorkspaceManagementService(ILogger logger, HttpClient httpClient, bool us
private readonly DescriptionStorageService descriptionStorageService;
public async Task<bool> IsConsumerPresentAsync(string clientName, CancellationToken cancellationToken = default)
{
DescriptionStorageService.ValidateConsumerName(clientName);
if (!UseKiotaConfig) return false;
var (wsConfig, _) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false);
return wsConfig is not null && (wsConfig.Clients.ContainsKey(clientName) || wsConfig.Plugins.ContainsKey(clientName));
Expand All @@ -68,6 +69,7 @@ private BaseApiConsumerConfiguration UpdateConsumerConfiguration(GenerationConfi
public async Task UpdateStateFromConfigurationAsync(GenerationConfiguration generationConfiguration, string descriptionHash, Dictionary<string, HashSet<string>> templatesWithOperations, Stream descriptionStream, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(generationConfiguration);
DescriptionStorageService.ValidateConsumerName(generationConfiguration.ClientClassName);
if (UseKiotaConfig)
{
var (wsConfig, manifest) = await LoadConfigurationAndManifestAsync(cancellationToken).ConfigureAwait(false);
Expand Down Expand Up @@ -154,6 +156,7 @@ public async Task<bool> ShouldGenerateAsync(GenerationConfiguration inputConfig,
}
public async Task<Stream?> GetDescriptionCopyAsync(string clientName, string inputPath, bool cleanOutput, CancellationToken cancellationToken = default)
{
DescriptionStorageService.ValidateConsumerName(clientName);
if (!UseKiotaConfig || cleanOutput)
return null;
return await descriptionStorageService.GetDescriptionAsync(clientName, new Uri(inputPath).GetFileExtension(), cancellationToken).ConfigureAwait(false);
Expand All @@ -178,6 +181,7 @@ public Task RemovePluginAsync(string clientName, bool cleanOutput = false, Cance
}
private async Task RemoveConsumerInternalAsync<T>(string consumerName, Func<WorkspaceConfiguration, Dictionary<string, T>> consumerRetrieval, bool cleanOutput, string consumerDisplayName, CancellationToken cancellationToken) where T : BaseApiConsumerConfiguration
{
DescriptionStorageService.ValidateConsumerName(consumerName);
if (!UseKiotaConfig)
throw new InvalidOperationException($"Cannot remove a {consumerDisplayName} in lock mode");
var (wsConfig, manifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,86 @@ public async Task DefensiveAsync()
await Assert.ThrowsAsync<ArgumentNullException>(() => service.UpdateDescriptionAsync("foo", null, cancellationToken: TestContext.Current.CancellationToken));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetDescriptionAsync(null, cancellationToken: TestContext.Current.CancellationToken));
}

[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("..")]
[InlineData("a/b")]
[InlineData("a\\b")]
[InlineData("./Victim")]
[InlineData(".")]
[InlineData(" ")]
public async Task UpdateDescriptionRejectsTraversalNamesAsync(string clientName)
{
var service = new DescriptionStorageService(tempPath);
using var stream = new MemoryStream();
stream.WriteByte(0x1);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync(clientName, stream, cancellationToken: TestContext.Current.CancellationToken));
}

[Fact]
public async Task UpdateDescriptionTraversalDoesNotEscapeConsumerNamespaceAsync()
{
var service = new DescriptionStorageService(tempPath);
using var victimStream = new MemoryStream();
victimStream.WriteByte(0x2);
await service.UpdateDescriptionAsync("Victim", victimStream, cancellationToken: TestContext.Current.CancellationToken);

using var maliciousStream = new MemoryStream();
maliciousStream.WriteByte(0x9);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync("junk/../Victim", maliciousStream, cancellationToken: TestContext.Current.CancellationToken));

// The victim's cached description must remain untouched (single byte 0x2 written above).
var victimFilePath = Path.Combine(tempPath, DescriptionStorageService.DescriptionsSubDirectoryRelativePath, "Victim", "openapi.yml");
Comment thread
gavinbarron marked this conversation as resolved.
Dismissed
Assert.True(File.Exists(victimFilePath));
var contents = await File.ReadAllBytesAsync(victimFilePath, TestContext.Current.CancellationToken);
Assert.Equal([0x2], contents);
}

[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task GetDescriptionRejectsTraversalNamesAsync(string clientName)
{
var service = new DescriptionStorageService(tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetDescriptionAsync(clientName, cancellationToken: TestContext.Current.CancellationToken));
}

[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public void RemoveDescriptionRejectsTraversalNames(string clientName)
{
var service = new DescriptionStorageService(tempPath);
Assert.Throws<InvalidOperationException>(() => service.RemoveDescription(clientName));
}

[Theory]
[InlineData("../evil")]
[InlineData("a/b")]
[InlineData("a\\b")]
[InlineData("")]
[InlineData(" ")]
public async Task UpdateDescriptionRejectsInvalidExtensionsAsync(string extension)
{
var service = new DescriptionStorageService(tempPath);
using var stream = new MemoryStream();
stream.WriteByte(0x1);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync("clientName", stream, extension, cancellationToken: TestContext.Current.CancellationToken));
}

[Theory]
[InlineData("../evil")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task GetDescriptionRejectsInvalidExtensionsAsync(string extension)
{
var service = new DescriptionStorageService(tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetDescriptionAsync("clientName", extension, cancellationToken: TestContext.Current.CancellationToken));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,50 @@ await WriteWorkspaceConfigurationAsync($$"""

await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetWorkspaceConfigurationAsync(cancellationToken: TestContext.Current.CancellationToken));
}
[InlineData("../Victim")]
[InlineData("junk/../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
[Theory]
public async Task RejectsClientNameWithTraversalKeyAsync(string clientName)
{
var service = new WorkspaceConfigurationStorageService(tempPath);
await WriteWorkspaceConfigurationAsync($$"""
{
"version": "1.0.0",
"clients": {
"{{clientName.Replace("\\", "\\\\", StringComparison.Ordinal)}}": {
"outputPath": "./client"
}
},
"plugins": {}
}
""");

await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetWorkspaceConfigurationAsync(cancellationToken: TestContext.Current.CancellationToken));
}
[InlineData("../Victim")]
[InlineData("junk/../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
[Theory]
public async Task RejectsPluginNameWithTraversalKeyAsync(string pluginName)
{
var service = new WorkspaceConfigurationStorageService(tempPath);
await WriteWorkspaceConfigurationAsync($$"""
{
"version": "1.0.0",
"clients": {},
"plugins": {
"{{pluginName.Replace("\\", "\\\\", StringComparison.Ordinal)}}": {
"outputPath": "./plugin"
}
}
}
""");

await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetWorkspaceConfigurationAsync(cancellationToken: TestContext.Current.CancellationToken));
}
private async Task WriteWorkspaceConfigurationAsync(string content)
{
var configurationDirectory = Path.Combine(tempPath, WorkspaceConfigurationStorageService.KiotaDirectorySegment);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,82 @@ await File.WriteAllTextAsync(descriptionPath, @$"openapi: 3.0.1
Assert.NotNull(descriptionCopy);
}

[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task RemoveClientRejectsTraversalNamesAsync(string clientName)
{
var mockLogger = Mock.Of<ILogger>();
Directory.CreateDirectory(tempPath);
var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.RemoveClientAsync(clientName, cancellationToken: TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task RemovePluginRejectsTraversalNamesAsync(string clientName)
{
var mockLogger = Mock.Of<ILogger>();
Directory.CreateDirectory(tempPath);
var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.RemovePluginAsync(clientName, cancellationToken: TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task IsConsumerPresentRejectsTraversalNamesAsync(string clientName)
{
var mockLogger = Mock.Of<ILogger>();
Directory.CreateDirectory(tempPath);
var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.IsConsumerPresentAsync(clientName, cancellationToken: TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task GetDescriptionCopyRejectsTraversalNamesAsync(string clientName)
{
var mockLogger = Mock.Of<ILogger>();
Directory.CreateDirectory(tempPath);
var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetDescriptionCopyAsync(clientName, Path.Combine(tempPath, "openapi.yml"), false, cancellationToken: TestContext.Current.CancellationToken));
Comment thread
gavinbarron marked this conversation as resolved.
Dismissed
}
[Theory]
[InlineData("junk/../Victim")]
[InlineData("../Victim")]
[InlineData("a/b")]
[InlineData("a\\b")]
public async Task UpdateStateRejectsTraversalClientNamesAsync(string clientName)
{
var mockLogger = Mock.Of<ILogger>();
Directory.CreateDirectory(tempPath);
var service = new WorkspaceManagementService(mockLogger, httpClient, true, tempPath);
var configuration = new GenerationConfiguration
{
ClientClassName = clientName,
OutputPath = Path.Combine(tempPath, "client"),
Comment thread
gavinbarron marked this conversation as resolved.
Dismissed
OpenAPIFilePath = Path.Combine(tempPath, "openapi.yaml"),
Comment thread
gavinbarron marked this conversation as resolved.
Dismissed
ApiRootUrl = "https://graph.microsoft.com",
};
using var stream = new MemoryStream();
stream.WriteByte(0x1);
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateStateFromConfigurationAsync(
configuration,
"foo",
new Dictionary<string, HashSet<string>> {
{ "/foo", new HashSet<string> { "GET" } }
},
stream, cancellationToken: TestContext.Current.CancellationToken));
}

public void Dispose()
{
if (Directory.Exists(tempPath))
Expand Down
Loading