Skip to content
Open
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
10 changes: 8 additions & 2 deletions dotnet/samples/Demos/CodeInterpreterPlugin/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,13 @@ async Task<string> 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;
Expand All @@ -73,7 +77,9 @@ async Task<string> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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") },
Expand Down Expand Up @@ -186,17 +187,21 @@ public void Dispose()
private sealed class HttpClientFactory : IHttpClientFactory, IDisposable
{
private readonly List<HttpClient> _httpClients = [];
private readonly List<HttpClientHandler> _httpClientHandlers = [];

public HttpClient CreateClient(string name)
{
var client = new HttpClient();
var handler = new HttpClientHandler { AllowAutoRedirect = false };
var client = new HttpClient(handler, disposeHandler: false);
this._httpClientHandlers.Add(handler);
this._httpClients.Add(client);
return client;
}

public void Dispose()
{
this._httpClients.ForEach(client => client.Dispose());
this._httpClientHandlers.ForEach(handler => handler.Dispose());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ public sealed partial class SessionsPythonPlugin
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="authTokenProvider">Optional provider for auth token generation.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <remarks>
/// The <paramref name="httpClientFactory"/> must create clients with automatic redirects disabled
/// to prevent redirects from bypassing <see cref="SessionsPythonSettings.AllowedDomains"/>.
/// </remarks>
public SessionsPythonPlugin(
SessionsPythonSettings settings,
IHttpClientFactory httpClientFactory,
Expand Down Expand Up @@ -271,11 +275,11 @@ private async Task<HttpResponseMessage> 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, StringComparer.OrdinalIgnoreCase) != true)
{
throw new InvalidOperationException("Sending requests to the provided location is not allowed.");
throw new InvalidOperationException(
$"Sending requests to host '{uri.Host}' is not allowed. Add the host to {nameof(SessionsPythonSettings.AllowedDomains)}.");
}
Comment thread
westey-m marked this conversation as resolved.

using var request = new HttpRequestMessage(method, uri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ public class SessionsPythonSettings
public Uri Endpoint { get; set; }

/// <summary>
/// List of allowed domains to download from.
/// Gets or sets the domains to which the plugin may send requests.
/// If <c>null</c> or empty, all requests are denied.
/// </summary>
/// <remarks>
/// Configure the HTTP client factory used by <see cref="SessionsPythonPlugin"/> with automatic redirects disabled
/// to prevent redirects from bypassing this allowlist.
/// </remarks>
public IEnumerable<string>? AllowedDomains { get; set; }

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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))! }
Expand Down Expand Up @@ -335,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)]
Expand All @@ -351,17 +354,63 @@ 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<InvalidOperationException>(() => sut.ListFilesAsync());
}

[Fact]
public async Task ItShouldRejectRedirectResponseFromNonRedirectingClientAsync()
{
// Arrange
await using var server = new RedirectLoopbackServer("metadata/instance", "application/json", []);
using var httpClientHandler = new HttpClientHandler { AllowAutoRedirect = false };
using var nonRedirectingClient = new HttpClient(httpClientHandler);
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).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<HttpOperationException>(() => sut.ListFilesAsync());
Assert.False(server.RedirectTargetContacted, "The redirect target should not have been contacted.");
}

[Fact]
Expand Down Expand Up @@ -539,6 +588,7 @@ public async Task ItShouldAllowDownloadDirectlyInsideAllowedDirectoryAsync()
sessionId: Guid.NewGuid().ToString(),
endpoint: new Uri("http://localhost:8888"))
{
AllowedDomains = ["localhost"],
AllowedDownloadDirectories = new[] { tempDir }
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading