diff --git a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs index fc43e1ba7299..f0b88b65bf0c 100644 --- a/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs +++ b/dotnet/src/Plugins/Plugins.Core/CodeInterpreter/SessionsPythonPlugin.cs @@ -271,9 +271,25 @@ 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) + // Validate the request host against the allowed domains to prevent SSRF. + // When AllowedDomains is explicitly configured, only those hosts are permitted: + // a request whose host is not on the list (including the configured endpoint's + // own host, e.g. IMDS at 169.254.169.254) is rejected. This is the effective + // SSRF guard and it now works correctly regardless of casing. + // + // When AllowedDomains is null (not configured) the plugin defaults to permitting + // only the configured endpoint's own host. Since every request in this type is + // built as a relative path against that endpoint, this is a readable, + // deterministic replacement for the previous nullable-bool expression rather than + // an additional restriction — it preserves backward-compatible behavior while + // removing the confusing "(!x) ?? false" fail-open construct. To actually + // restrict which host the plugin talks to, configure AllowedDomains explicitly. + // Host comparison is ordinal-ignore-case to match DNS semantics and the + // convention used by other plugins in this repository. + var effectiveAllowedDomains = this._settings.AllowedDomains + ?? new[] { this._poolManagementEndpoint.Host }; + + if (!effectiveAllowedDomains.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) { throw new InvalidOperationException("Sending requests to the provided location is not allowed."); } diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs index e4fc1cf2de12..ddfcbe3c1f5f 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Core/SessionsPythonPluginTests.cs @@ -338,6 +338,7 @@ public async Task ItShouldDownloadFileSavingInDiskAsync() [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)] + [InlineData("FAKE-TEST-HOST.io", "https://fake-test-host.io/subscriptions/123/rg/456/sps/test-pool", true)] public async Task ItShouldRespectAllowedDomainsAsync(string allowedDomain, string actualEndpoint, bool isAllowed) { // Arrange @@ -364,6 +365,61 @@ public async Task ItShouldRespectAllowedDomainsAsync(string allowedDomain, strin #pragma warning restore CA1031 // Do not catch general exception types } + /// + /// When AllowedDomains is null (not configured), requests to the configured endpoint's + /// own host should succeed — the plugin defaults to allowing only that host. + /// + [Fact] + public async Task ItShouldAllowConfiguredEndpointHostWhenAllowedDomainsIsNullAsync() + { + // Arrange — AllowedDomains is null (default) + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("https://eastus.acasessions.io/subscriptions/123/rg/456/sps/test-pool")) + { + AllowedDomains = null + }; + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(File.ReadAllText(ListFilesTestDataFilePath)), + }; + + var sut = new SessionsPythonPlugin(settings, this._httpClientFactory); + + // Act & Assert — should NOT throw; the endpoint host is implicitly allowed + var files = await sut.ListFilesAsync(); + Assert.NotNull(files); + + // The request must actually be sent to the configured endpoint host. + Assert.Equal("eastus.acasessions.io", this._messageHandlerStub.RequestUri?.Host); + } + + /// + /// When AllowedDomains is explicitly configured, a request whose host is not in the + /// allowlist must be blocked — even when that host is the configured endpoint itself. + /// This prevents SSRF to arbitrary targets such as IMDS (169.254.169.254). + /// + [Fact] + public async Task ItShouldBlockEndpointHostNotInAllowedDomainsAsync() + { + // Arrange — endpoint points at IMDS, but the allowlist only permits a legitimate host. + var settings = new SessionsPythonSettings( + sessionId: Guid.NewGuid().ToString(), + endpoint: new Uri("http://169.254.169.254/metadata/instance")) + { + AllowedDomains = new[] { "eastus.acasessions.io" } + }; + + var sut = new SessionsPythonPlugin(settings, this._httpClientFactory); + + // Act & Assert — should throw because 169.254.169.254 is not in AllowedDomains. + var exception = await Assert.ThrowsAsync( + () => sut.ListFilesAsync()); + + Assert.Contains("not allowed", exception.Message); + } + [Fact] public async Task ItShouldAddHeadersAsync() {