Skip to content

Commit d5937fd

Browse files
authored
Merge branch 'main' into apigurus
2 parents 11d8539 + 9d4f80e commit d5937fd

14 files changed

Lines changed: 334 additions & 18 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Changed
1515

16+
## [1.34.0] - 2026-07-08
17+
18+
### Added
19+
20+
### Changed
21+
22+
- 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)
23+
1624
## [1.33.0] - 2026-07-06
1725

1826
### Added

it/python/requirements-dev.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ astroid==4.0.4 ; python_full_version >= '3.7.2'
44

55
certifi==2026.6.17 ; python_version >= '3.6'
66

7-
charset-normalizer==3.4.8 ; python_full_version >= '3.7.0'
7+
charset-normalizer==3.4.9 ; python_full_version >= '3.7.0'
88

99
colorama==0.4.6 ; sys_platform == 'win32'
1010

@@ -30,7 +30,7 @@ lazy-object-proxy==1.12.0 ; python_version >= '3.7'
3030

3131
mccabe==0.7.0 ; python_version >= '3.6'
3232

33-
mypy==2.1.0
33+
mypy==2.2.0
3434

3535
mypy-extensions==1.1.0 ; python_version >= '3.5'
3636

it/typescript/package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

it/typescript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"devDependencies": {
2121
"@es-exec/esbuild-plugin-start": "^0.0.5",
2222
"@stylistic/eslint-plugin-ts": "^4.4.1",
23-
"@types/node": "^26.1.0",
23+
"@types/node": "^26.1.1",
2424
"@typescript-eslint/eslint-plugin": "^8.63.0",
2525
"@typescript-eslint/parser": "^8.32.1",
2626
"esbuild": "^0.28.1",

src/Kiota.Builder/OpenApiExtensions/OpenApiAiCapabilitiesExtension.cs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using System.Text.Json;
45
using System.Text.Json.Nodes;
56
using Kiota.Builder.Extensions;
@@ -386,23 +387,68 @@ fileNode is JsonValue fileValue &&
386387
// Inlined cards (no "file" property) are always considered safe.
387388
public bool HasUnsafeFileReference => File is not null && !IsSafeFileReference(File);
388389

390+
// Upper bound on percent-decode passes; enough to defeat multi-level (double) encoding without unbounded looping.
391+
private const int MaxPercentDecodePasses = 5;
392+
389393
// Validates that a static_template file reference is a relative path that cannot point outside the manifest
390394
// package: rejects absolute URIs, POSIX/UNC rooted paths, Windows drive paths, and '..' traversal (CWE-22/CWE-829).
395+
// The reference is percent-decoded first so encoded traversal sequences (e.g. %2e%2e for '..', %2f for '/',
396+
// %3a for ':') cannot bypass the checks below; decoding is repeated to defeat multi-level encoding, residual
397+
// encoding beyond the decode budget fails closed, embedded control/NUL characters are rejected, and Unicode
398+
// compatibility forms are folded so full-width homoglyph traversal cannot slip through.
391399
public static bool IsSafeFileReference(string? file)
392400
{
393401
if (string.IsNullOrWhiteSpace(file))
394402
{
395403
return false;
396404
}
397405

406+
// Percent-decode repeatedly until the value is stable so encoded (and double-encoded) traversal payloads
407+
// are normalized back to their literal form before validation.
408+
var decoded = file;
409+
for (var pass = 0; pass < MaxPercentDecodePasses; pass++)
410+
{
411+
var next = Uri.UnescapeDataString(decoded);
412+
if (string.Equals(next, decoded, StringComparison.Ordinal))
413+
{
414+
break;
415+
}
416+
decoded = next;
417+
}
418+
419+
if (string.IsNullOrWhiteSpace(decoded))
420+
{
421+
return false;
422+
}
423+
424+
// Fail closed if percent-encoding remains after the decode budget is exhausted: undecoded residue
425+
// (e.g. more encoding levels than MaxPercentDecodePasses) could still be decoded by the downstream
426+
// consumer into a traversal sequence, so treat it as unsafe rather than accepting it verbatim.
427+
if (!string.Equals(Uri.UnescapeDataString(decoded), decoded, StringComparison.Ordinal))
428+
{
429+
return false;
430+
}
431+
432+
// Fold Unicode compatibility forms (e.g. full-width '.'/'/') to their canonical ASCII equivalents so
433+
// homoglyph traversal payloads are normalized before the checks below. Validation only; the original
434+
// reference is still emitted verbatim.
435+
decoded = decoded.Normalize(System.Text.NormalizationForm.FormKC);
436+
437+
// Reject control characters (e.g. an embedded NUL from %00) which can truncate the path in downstream
438+
// consumers and defeat the parent-directory segment check below.
439+
if (decoded.Any(char.IsControl))
440+
{
441+
return false;
442+
}
443+
398444
// The manifest schema requires a relative file path; reject absolute URIs such as http(s):// or file://.
399-
if (Uri.TryCreate(file, UriKind.Absolute, out _))
445+
if (Uri.TryCreate(decoded, UriKind.Absolute, out _))
400446
{
401447
return false;
402448
}
403449

404450
// Normalize separators so the checks below are OS-independent (the manifest is consumed on any platform).
405-
var normalized = file.Replace('\\', '/');
451+
var normalized = decoded.Replace('\\', '/');
406452

407453
// Reject POSIX-absolute and UNC-style rooted paths (e.g. /etc/passwd, //server/share).
408454
if (normalized.StartsWith('/'))

src/Kiota.Builder/WorkspaceManagement/DescriptionStorageService.cs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.IO;
3+
using System.Linq;
34
using System.Threading;
45
using System.Threading.Tasks;
56
using AsyncKeyedLock;
@@ -21,7 +22,39 @@ public DescriptionStorageService(string targetDirectory)
2122
o.PoolSize = 20;
2223
o.PoolInitialFill = 1;
2324
});
24-
private string GetDescriptionFilePath(string clientName, string extension) => Path.Combine(TargetDirectory, DescriptionsSubDirectoryRelativePath, clientName, $"openapi.{extension}");
25+
private string GetDescriptionFilePath(string clientName, string extension)
26+
{
27+
ValidateConsumerName(clientName);
28+
ValidateExtension(extension);
29+
var documentsDirectory = Path.Join(TargetDirectory, DescriptionsSubDirectoryRelativePath);
30+
var descriptionFilePath = Path.GetFullPath(Path.Combine(documentsDirectory, clientName, $"openapi.{extension}"));
31+
var documentsFullPath = Path.GetFullPath(documentsDirectory);
32+
var documentsFullPathWithSeparator = Path.EndsInDirectorySeparator(documentsFullPath) ? documentsFullPath : documentsFullPath + Path.DirectorySeparatorChar;
33+
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
34+
if (!descriptionFilePath.StartsWith(documentsFullPathWithSeparator, comparison))
35+
throw new InvalidOperationException($"The consumer name '{clientName}' resolves to a path outside of the documents directory.");
36+
return descriptionFilePath;
37+
}
38+
internal static void ValidateConsumerName(string clientName)
39+
{
40+
if (string.IsNullOrWhiteSpace(clientName))
41+
throw new InvalidOperationException("The consumer name must not be empty or whitespace.");
42+
if (Path.IsPathRooted(clientName) ||
43+
clientName.Contains('/', StringComparison.Ordinal) ||
44+
clientName.Contains('\\', StringComparison.Ordinal) ||
45+
clientName.Split('/', '\\').Contains("..", StringComparer.Ordinal) ||
46+
clientName is "." or "..")
47+
throw new InvalidOperationException($"The consumer name '{clientName}' is not a valid single path segment and cannot navigate the file system.");
48+
}
49+
private static void ValidateExtension(string extension)
50+
{
51+
if (string.IsNullOrWhiteSpace(extension))
52+
throw new InvalidOperationException("The description file extension must not be empty or whitespace.");
53+
if (Path.IsPathRooted(extension) ||
54+
extension.Contains('/', StringComparison.Ordinal) ||
55+
extension.Contains('\\', StringComparison.Ordinal))
56+
throw new InvalidOperationException($"The description file extension '{extension}' must not contain path separators or be rooted.");
57+
}
2558
public async Task UpdateDescriptionAsync(string clientName, Stream description, string extension = "yml", CancellationToken cancellationToken = default)
2659
{
2760
ArgumentNullException.ThrowIfNull(clientName);
@@ -62,7 +95,7 @@ public void RemoveDescription(string clientName, string extension = "yml")
6295
}
6396
public void Clean()
6497
{
65-
var kiotaDirectoryPath = Path.Combine(TargetDirectory, DescriptionsSubDirectoryRelativePath);
98+
var kiotaDirectoryPath = Path.Join(TargetDirectory, DescriptionsSubDirectoryRelativePath);
6699
if (Path.Exists(kiotaDirectoryPath))
67100
Directory.Delete(kiotaDirectoryPath, true);
68101
}

src/Kiota.Builder/WorkspaceManagement/WorkspaceConfigurationStorageService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,22 @@ private void ValidateConsumerOutputPaths(WorkspaceConfiguration? configuration)
111111
foreach (var client in configuration.Clients)
112112
try
113113
{
114+
DescriptionStorageService.ValidateConsumerName(client.Key);
114115
BaseApiConsumerConfiguration.ValidateOutputPath(client.Value.OutputPath, workspaceDirectory);
115116
}
116117
catch (InvalidOperationException ex)
117118
{
118-
throw new InvalidOperationException($"The client {client.Key} has an invalid output path: {ex.Message}", ex);
119+
throw new InvalidOperationException($"The client {client.Key} has an invalid configuration: {ex.Message}", ex);
119120
}
120121
foreach (var plugin in configuration.Plugins)
121122
try
122123
{
124+
DescriptionStorageService.ValidateConsumerName(plugin.Key);
123125
BaseApiConsumerConfiguration.ValidateOutputPath(plugin.Value.OutputPath, workspaceDirectory);
124126
}
125127
catch (InvalidOperationException ex)
126128
{
127-
throw new InvalidOperationException($"The plugin {plugin.Key} has an invalid output path: {ex.Message}", ex);
129+
throw new InvalidOperationException($"The plugin {plugin.Key} has an invalid configuration: {ex.Message}", ex);
128130
}
129131
}
130132
public async Task BackupConfigAsync(CancellationToken cancellationToken = default)

src/Kiota.Builder/WorkspaceManagement/WorkspaceManagementService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public WorkspaceManagementService(ILogger logger, HttpClient httpClient, bool us
4242
private readonly DescriptionStorageService descriptionStorageService;
4343
public async Task<bool> IsConsumerPresentAsync(string clientName, CancellationToken cancellationToken = default)
4444
{
45+
DescriptionStorageService.ValidateConsumerName(clientName);
4546
if (!UseKiotaConfig) return false;
4647
var (wsConfig, _) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false);
4748
return wsConfig is not null && (wsConfig.Clients.ContainsKey(clientName) || wsConfig.Plugins.ContainsKey(clientName));
@@ -68,6 +69,7 @@ private BaseApiConsumerConfiguration UpdateConsumerConfiguration(GenerationConfi
6869
public async Task UpdateStateFromConfigurationAsync(GenerationConfiguration generationConfiguration, string descriptionHash, Dictionary<string, HashSet<string>> templatesWithOperations, Stream descriptionStream, CancellationToken cancellationToken = default)
6970
{
7071
ArgumentNullException.ThrowIfNull(generationConfiguration);
72+
DescriptionStorageService.ValidateConsumerName(generationConfiguration.ClientClassName);
7173
if (UseKiotaConfig)
7274
{
7375
var (wsConfig, manifest) = await LoadConfigurationAndManifestAsync(cancellationToken).ConfigureAwait(false);
@@ -154,6 +156,7 @@ public async Task<bool> ShouldGenerateAsync(GenerationConfiguration inputConfig,
154156
}
155157
public async Task<Stream?> GetDescriptionCopyAsync(string clientName, string inputPath, bool cleanOutput, CancellationToken cancellationToken = default)
156158
{
159+
DescriptionStorageService.ValidateConsumerName(clientName);
157160
if (!UseKiotaConfig || cleanOutput)
158161
return null;
159162
return await descriptionStorageService.GetDescriptionAsync(clientName, new Uri(inputPath).GetFileExtension(), cancellationToken).ConfigureAwait(false);
@@ -178,6 +181,7 @@ public Task RemovePluginAsync(string clientName, bool cleanOutput = false, Cance
178181
}
179182
private async Task RemoveConsumerInternalAsync<T>(string consumerName, Func<WorkspaceConfiguration, Dictionary<string, T>> consumerRetrieval, bool cleanOutput, string consumerDisplayName, CancellationToken cancellationToken) where T : BaseApiConsumerConfiguration
180183
{
184+
DescriptionStorageService.ValidateConsumerName(consumerName);
181185
if (!UseKiotaConfig)
182186
throw new InvalidOperationException($"Cannot remove a {consumerDisplayName} in lock mode");
183187
var (wsConfig, manifest) = await workspaceConfigurationStorageService.GetWorkspaceConfigurationAsync(cancellationToken).ConfigureAwait(false);

tests/Kiota.Builder.Tests/OpenApiExtensions/OpenApiAiCapabilitiesExtensionTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,27 @@ public void Serializes()
269269
[InlineData("http://attacker.example/exfil", false)]
270270
[InlineData("https://attacker.example/card.json", false)]
271271
[InlineData("file:///etc/passwd", false)]
272+
// Percent-encoded traversal / URIs must be decoded before validation (CWE-22 / CWE-829).
273+
[InlineData("%2e%2e/card.json", false)]
274+
[InlineData("..%2f..%2f..%2f..%2f..%2f..%2fetc%2fpasswd", false)]
275+
[InlineData("file%3A%2F%2F%2Fetc%2Fpasswd", false)]
276+
[InlineData("%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", false)]
277+
// Encoding hardening variants.
278+
[InlineData("%2E%2E/card.json", false)]
279+
[InlineData("..%5c..%5csecret.json", false)]
280+
[InlineData("https%3A%2F%2Fattacker.example%2Fcard.json", false)]
281+
[InlineData("%252e%252e%252fcard.json", false)]
282+
// A benign filename containing an encoded space stays safe after decoding.
283+
[InlineData("card%20name.json", true)]
284+
// Encoded NUL / control characters must be rejected (truncation + segment-check evasion).
285+
[InlineData("card%00.json", false)]
286+
[InlineData("safe.json%00%2e%2e%2fetc%2fpasswd", false)]
287+
// Encoding deeper than the decode budget must fail closed rather than pass residual %XX through.
288+
[InlineData("%25252525252e%25252525252e%25252525252fx", false)]
289+
[InlineData("%2525252525252e%2525252525252e%2525252525252fx", false)]
290+
// Unicode full-width homoglyph traversal (literal and percent-encoded UTF-8) is folded and rejected.
291+
[InlineData("\uFF0E\uFF0E/card.json", false)]
292+
[InlineData("%EF%BC%8E%EF%BC%8E/card.json", false)]
272293
public void StaticTemplateIsSafeFileReferenceValidatesPaths(string file, bool expectedSafe)
273294
{
274295
Assert.Equal(expectedSafe, ExtensionResponseSemanticsStaticTemplate.IsSafeFileReference(file));

tests/Kiota.Builder.Tests/WorkspaceManagement/DescriptionStorageServiceTests.cs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,86 @@ public async Task DefensiveAsync()
4646
await Assert.ThrowsAsync<ArgumentNullException>(() => service.UpdateDescriptionAsync("foo", null, cancellationToken: TestContext.Current.CancellationToken));
4747
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetDescriptionAsync(null, cancellationToken: TestContext.Current.CancellationToken));
4848
}
49+
50+
[Theory]
51+
[InlineData("junk/../Victim")]
52+
[InlineData("../Victim")]
53+
[InlineData("..")]
54+
[InlineData("a/b")]
55+
[InlineData("a\\b")]
56+
[InlineData("./Victim")]
57+
[InlineData(".")]
58+
[InlineData(" ")]
59+
public async Task UpdateDescriptionRejectsTraversalNamesAsync(string clientName)
60+
{
61+
var service = new DescriptionStorageService(tempPath);
62+
using var stream = new MemoryStream();
63+
stream.WriteByte(0x1);
64+
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync(clientName, stream, cancellationToken: TestContext.Current.CancellationToken));
65+
}
66+
67+
[Fact]
68+
public async Task UpdateDescriptionTraversalDoesNotEscapeConsumerNamespaceAsync()
69+
{
70+
var service = new DescriptionStorageService(tempPath);
71+
using var victimStream = new MemoryStream();
72+
victimStream.WriteByte(0x2);
73+
await service.UpdateDescriptionAsync("Victim", victimStream, cancellationToken: TestContext.Current.CancellationToken);
74+
75+
using var maliciousStream = new MemoryStream();
76+
maliciousStream.WriteByte(0x9);
77+
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync("junk/../Victim", maliciousStream, cancellationToken: TestContext.Current.CancellationToken));
78+
79+
// The victim's cached description must remain untouched (single byte 0x2 written above).
80+
var victimFilePath = Path.Combine(tempPath, DescriptionStorageService.DescriptionsSubDirectoryRelativePath, "Victim", "openapi.yml");
81+
Assert.True(File.Exists(victimFilePath));
82+
var contents = await File.ReadAllBytesAsync(victimFilePath, TestContext.Current.CancellationToken);
83+
Assert.Equal([0x2], contents);
84+
}
85+
86+
[Theory]
87+
[InlineData("junk/../Victim")]
88+
[InlineData("../Victim")]
89+
[InlineData("a/b")]
90+
[InlineData("a\\b")]
91+
public async Task GetDescriptionRejectsTraversalNamesAsync(string clientName)
92+
{
93+
var service = new DescriptionStorageService(tempPath);
94+
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetDescriptionAsync(clientName, cancellationToken: TestContext.Current.CancellationToken));
95+
}
96+
97+
[Theory]
98+
[InlineData("junk/../Victim")]
99+
[InlineData("../Victim")]
100+
[InlineData("a/b")]
101+
[InlineData("a\\b")]
102+
public void RemoveDescriptionRejectsTraversalNames(string clientName)
103+
{
104+
var service = new DescriptionStorageService(tempPath);
105+
Assert.Throws<InvalidOperationException>(() => service.RemoveDescription(clientName));
106+
}
107+
108+
[Theory]
109+
[InlineData("../evil")]
110+
[InlineData("a/b")]
111+
[InlineData("a\\b")]
112+
[InlineData("")]
113+
[InlineData(" ")]
114+
public async Task UpdateDescriptionRejectsInvalidExtensionsAsync(string extension)
115+
{
116+
var service = new DescriptionStorageService(tempPath);
117+
using var stream = new MemoryStream();
118+
stream.WriteByte(0x1);
119+
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateDescriptionAsync("clientName", stream, extension, cancellationToken: TestContext.Current.CancellationToken));
120+
}
121+
122+
[Theory]
123+
[InlineData("../evil")]
124+
[InlineData("a/b")]
125+
[InlineData("a\\b")]
126+
public async Task GetDescriptionRejectsInvalidExtensionsAsync(string extension)
127+
{
128+
var service = new DescriptionStorageService(tempPath);
129+
await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetDescriptionAsync("clientName", extension, cancellationToken: TestContext.Current.CancellationToken));
130+
}
49131
}

0 commit comments

Comments
 (0)