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
Original file line number Diff line number Diff line change
Expand Up @@ -271,9 +271,25 @@ 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)
// 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.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -364,6 +365,61 @@ public async Task ItShouldRespectAllowedDomainsAsync(string allowedDomain, strin
#pragma warning restore CA1031 // Do not catch general exception types
}

/// <summary>
/// When AllowedDomains is null (not configured), requests to the configured endpoint's
/// own host should succeed — the plugin defaults to allowing only that host.
/// </summary>
[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);
}

/// <summary>
/// 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).
/// </summary>
[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<InvalidOperationException>(
() => sut.ListFilesAsync());

Assert.Contains("not allowed", exception.Message);
}

[Fact]
public async Task ItShouldAddHeadersAsync()
{
Expand Down
Loading