From df581ff6eae8f1da431970b3bf8a653a63f3b0b3 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:10:54 +0100 Subject: [PATCH 1/5] Bug fix --- .../Demos/CodeInterpreterPlugin/Program.cs | 6 ++- .../Plugins/Core/SessionsPythonPluginTests.cs | 1 + .../CodeInterpreter/SessionsPythonPlugin.cs | 7 ++-- .../CodeInterpreter/SessionsPythonSettings.cs | 3 +- .../Core/SessionsPythonPluginTests.cs | 38 +++++++++++++++---- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs index 6b5024bc4a7e..06b55cc29cb6 100644 --- a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs +++ b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs @@ -54,9 +54,13 @@ async Task TokenProvider(CancellationToken cancellationToken) return cachedToken; } +var sessionEndpoint = new Uri(endpoint); var settings = new SessionsPythonSettings( sessionId: Guid.NewGuid().ToString(), - endpoint: new Uri(endpoint)); + endpoint: sessionEndpoint) +{ + AllowedDomains = [sessionEndpoint.Host] +}; // Uncomment the following lines to enable file upload operations (disabled by default for security) // settings.EnableDangerousFileUploads = true; diff --git a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs index 10898b5eed75..33c1144f47fd 100644 --- a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs @@ -45,6 +45,7 @@ public SessionsPythonPluginTests() { CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline, + AllowedDomains = [new Uri(_spConfiguration.Endpoint).Host], // Enable file operations for integration tests EnableDangerousFileUploads = true, AllowedUploadDirectories = new[] { Path.GetFullPath("TestData") }, diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs index fc43e1ba7299..2a8e555d8103 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs @@ -271,11 +271,10 @@ private async Task SendAsync(HttpClient httpClient, HttpMet var uri = new Uri(this._poolManagementEndpoint, pathWithQueryString); - // If a list of allowed domains has been provided, the host of the provided - // uri is checked to verify it is in the allowed domain list. - if (!this._settings.AllowedDomains?.Contains(uri.Host) ?? false) + // Deny requests unless the endpoint host is explicitly allowed. + if (this._settings.AllowedDomains?.Contains(uri.Host) != true) { - throw new InvalidOperationException("Sending requests to the provided location is not allowed."); + throw new InvalidOperationException("Sending requests to the provided location is not allowed, add allowed domains to the AllowedDomains property on the SessionsPythonSettings."); } using var request = new HttpRequestMessage(method, uri) diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs index f2a6076bd449..537ccca7b96a 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs @@ -25,7 +25,8 @@ public class SessionsPythonSettings public Uri Endpoint { get; set; } /// - /// List of allowed domains to download from. + /// Gets or sets the domains to which the plugin may send requests. + /// If null or empty, all requests are denied. /// public IEnumerable? AllowedDomains { get; set; } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs index e4fc1cf2de12..34c5f03a0f4a 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs @@ -33,7 +33,8 @@ public sealed class SessionsPythonPluginTests : IDisposable endpoint: new Uri("http://localhost:8888")) { CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, - CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline + CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline, + AllowedDomains = ["localhost"] }; private readonly SessionsPythonSettings _settingsWithFileOperationsEnabled; @@ -57,6 +58,7 @@ public SessionsPythonPluginTests() { CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline, + AllowedDomains = ["localhost"], EnableDangerousFileUploads = true, AllowedUploadDirectories = new[] { Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))! }, AllowedDownloadDirectories = new[] { Path.GetDirectoryName(Path.GetFullPath(FileTestDataFilePath))! } @@ -351,17 +353,39 @@ public async Task ItShouldRespectAllowedDomainsAsync(string allowedDomain, strin var sut = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); - // Act -#pragma warning disable CA1031 // Do not catch general exception types - try + // Act and assert + if (isAllowed) { await sut.ListFilesAsync(); } - catch when (!isAllowed) + else { - // Ignore exception if the endpoint is not allowed since we expect it + await Assert.ThrowsAsync(() => sut.ListFilesAsync()); } -#pragma warning restore CA1031 // Do not catch general exception types + } + + [Fact] + public async Task ItShouldDenyRequestsWhenAllowedDomainsIsNullAsync() + { + // Arrange + this._defaultSettings.AllowedDomains = null; + this._defaultSettings.Endpoint = new Uri("http://169.254.169.254/metadata/instance"); + var sut = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); + + // Act and assert + await Assert.ThrowsAsync(() => sut.ListFilesAsync()); + } + + [Fact] + public async Task ItShouldDenyRequestsWhenAllowedDomainsIsEmptyAsync() + { + // Arrange + this._defaultSettings.AllowedDomains = []; + this._defaultSettings.Endpoint = new Uri("http://169.254.169.254/metadata/instance"); + var sut = new SessionsPythonPlugin(this._defaultSettings, this._httpClientFactory); + + // Act and assert + await Assert.ThrowsAsync(() => sut.ListFilesAsync()); } [Fact] From 724aa1a2b24998d00cd3b02a1cd4b0f9feb42158 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:55:46 +0100 Subject: [PATCH 2/5] Address PR comments --- .../Demos/CodeInterpreterPlugin/Program.cs | 4 ++- .../Plugins/Core/SessionsPythonPluginTests.cs | 2 +- .../CodeInterpreter/SessionsPythonPlugin.cs | 9 +++++-- .../CodeInterpreter/SessionsPythonSettings.cs | 4 +++ .../Core/SessionsPythonPluginTests.cs | 25 +++++++++++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs index 06b55cc29cb6..35cd4c537ba2 100644 --- a/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs +++ b/dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs @@ -77,7 +77,9 @@ async Task TokenProvider(CancellationToken cancellationToken) // Change the log level to Trace to see more detailed logs builder.Services.AddLogging(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information)); -builder.Services.AddHttpClient(); +builder.Services + .AddHttpClient(string.Empty) + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddSingleton((sp) => new SessionsPythonPlugin( settings, diff --git a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs index 33c1144f47fd..941b5580ef9b 100644 --- a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs @@ -190,7 +190,7 @@ private sealed class HttpClientFactory : IHttpClientFactory, IDisposable public HttpClient CreateClient(string name) { - var client = new HttpClient(); + var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); this._httpClients.Add(client); return client; } diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs index 2a8e555d8103..9433de2a86bb 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs @@ -37,6 +37,10 @@ public sealed partial class SessionsPythonPlugin /// The HTTP client factory. /// Optional provider for auth token generation. /// The logger factory. + /// + /// The must create clients with automatic redirects disabled + /// to prevent redirects from bypassing . + /// public SessionsPythonPlugin( SessionsPythonSettings settings, IHttpClientFactory httpClientFactory, @@ -272,9 +276,10 @@ private async Task SendAsync(HttpClient httpClient, HttpMet var uri = new Uri(this._poolManagementEndpoint, pathWithQueryString); // Deny requests unless the endpoint host is explicitly allowed. - if (this._settings.AllowedDomains?.Contains(uri.Host) != true) + if (this._settings.AllowedDomains?.Contains(uri.Host, StringComparer.OrdinalIgnoreCase) != true) { - throw new InvalidOperationException("Sending requests to the provided location is not allowed, add allowed domains to the AllowedDomains property on the SessionsPythonSettings."); + throw new InvalidOperationException( + $"Sending requests to host '{uri.Host}' is not allowed. Add the host to {nameof(SessionsPythonSettings.AllowedDomains)}."); } using var request = new HttpRequestMessage(method, uri) diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs index 537ccca7b96a..b252f410f3ea 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonSettings.cs @@ -28,6 +28,10 @@ public class SessionsPythonSettings /// Gets or sets the domains to which the plugin may send requests. /// If null or empty, all requests are denied. /// + /// + /// Configure the HTTP client factory used by with automatic redirects disabled + /// to prevent redirects from bypassing this allowlist. + /// public IEnumerable? AllowedDomains { get; set; } /// diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs index 34c5f03a0f4a..ed736cceeb14 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs @@ -337,6 +337,7 @@ public async Task ItShouldDownloadFileSavingInDiskAsync() [InlineData("prod.fake-test-host.io", "https://prod.fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", true)] [InlineData("www.fake-test-host.io", "https://www.fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", true)] [InlineData("www.prod.fake-test-host.io", "https://www.prod.fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", true)] + [InlineData("FAKE-TEST-HOST.IO", "https://fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", true)] [InlineData("fake-test-host.io", "https://fake-test-host-1.io/subscriptions/123/rg/456/sps/test-pool", false)] [InlineData("fake-test-host.io", "https://www.fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", false)] [InlineData("www.fake-test-host.io", "https://fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", false)] @@ -388,6 +389,29 @@ public async Task ItShouldDenyRequestsWhenAllowedDomainsIsEmptyAsync() await Assert.ThrowsAsync(() => sut.ListFilesAsync()); } + [Fact] + public async Task ItShouldRejectRedirectResponseFromNonRedirectingClientAsync() + { + // Arrange + await using var server = new RedirectLoopbackServer("metadata/instance", "application/json", []); + using var nonRedirectingClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); + var httpClientFactoryMock = new Mock(); + httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(nonRedirectingClient); + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri(server.BaseUri, "start")) + { + AllowedDomains = [server.BaseUri.Host], + CodeExecutionType = SessionsPythonSettings.CodeExecutionTypeSetting.Synchronous, + CodeInputType = SessionsPythonSettings.CodeInputTypeSetting.Inline + }; + var sut = new SessionsPythonPlugin(settings, httpClientFactoryMock.Object); + + // Act and assert + await Assert.ThrowsAsync(() => sut.ListFilesAsync()); + Assert.False(server.RedirectTargetContacted, "The redirect target should not have been contacted."); + } + [Fact] public async Task ItShouldAddHeadersAsync() { @@ -563,6 +587,7 @@ public async Task ItShouldAllowDownloadDirectlyInsideAllowedDirectoryAsync() sessionId: Guid.NewGuid().ToString(), endpoint: new Uri("http://localhost:8888")) { + AllowedDomains = ["localhost"], AllowedDownloadDirectories = new[] { tempDir } }; From 59a54b8e5b64d932adb569ffe01224f1f6e8519e Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:02:57 +0100 Subject: [PATCH 3/5] Fix formatting issue --- .../Plugins.UnitTests/Core/SessionsPythonPluginTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs index ed736cceeb14..7899f6fb9c2d 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs @@ -394,7 +394,8 @@ public async Task ItShouldRejectRedirectResponseFromNonRedirectingClientAsync() { // Arrange await using var server = new RedirectLoopbackServer("metadata/instance", "application/json", []); - using var nonRedirectingClient = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); + using var httpClientHandler = new HttpClientHandler { AllowAutoRedirect = false }; + using var nonRedirectingClient = new HttpClient(httpClientHandler); var httpClientFactoryMock = new Mock(); httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny())).Returns(nonRedirectingClient); var settings = new SessionsPythonSettings( From 6f8e5d6718a563b27c0fab7a96dfa414a90647a9 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:18:08 +0100 Subject: [PATCH 4/5] Fix test --- .../Plugins/Core/SessionsPythonPluginTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs index 941b5580ef9b..e734d487b4c6 100644 --- a/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/IntegrationTests/Plugins/Core/SessionsPythonPluginTests.cs @@ -187,10 +187,13 @@ public void Dispose() private sealed class HttpClientFactory : IHttpClientFactory, IDisposable { private readonly List _httpClients = []; + private readonly List _httpClientHandlers = []; public HttpClient CreateClient(string name) { - var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }); + var handler = new HttpClientHandler { AllowAutoRedirect = false }; + var client = new HttpClient(handler, disposeHandler: false); + this._httpClientHandlers.Add(handler); this._httpClients.Add(client); return client; } @@ -198,6 +201,7 @@ public HttpClient CreateClient(string name) public void Dispose() { this._httpClients.ForEach(client => client.Dispose()); + this._httpClientHandlers.ForEach(handler => handler.Dispose()); } } } From 2cd1c95c4bbfb06f82f993193eaf3489e1d46596 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:41:44 +0100 Subject: [PATCH 5/5] Fix unit test --- .../Plugins.UnitTests/Support/RedirectLoopbackServer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs b/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs index 162dff67ebda..63bec9f3c60c 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Support/RedirectLoopbackServer.cs @@ -106,7 +106,8 @@ private async Task HandleRequestAsync(TcpClient client, CancellationToken cancel while (!string.IsNullOrEmpty(header)); var requestTarget = requestLine?.Split(' ')[1]; - if (requestTarget == "/start") + var requestPath = requestTarget?.Split('?')[0]; + if (!string.Equals(requestPath, this._redirectTargetPath, StringComparison.Ordinal)) { var response = $"HTTP/1.1 302 Found\r\nLocation: {new Uri(this.BaseUri, this._redirectTargetPath.TrimStart('/'))}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(response), cancellationToken).ConfigureAwait(false);