diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index d5bf54678a25..adcbbbab86a8 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -247,12 +247,19 @@ private void ValidateUrl(Uri url) if (this._serverUrlValidationOptions.AllowedBaseUrls is { Count: > 0 } allowedBaseUrls) { bool baseUrlAllowed = false; - var urlString = url.AbsoluteUri; foreach (var baseUrl in allowedBaseUrls) { - var baseUrlString = baseUrl.AbsoluteUri; - if (urlString.StartsWith(baseUrlString, StringComparison.OrdinalIgnoreCase)) + // Use only scheme + authority + path for comparison, ignoring any query or fragment. + var baseUrlPath = baseUrl.GetLeftPart(UriPartial.Path); + var urlPath = url.GetLeftPart(UriPartial.Path); + var baseUrlWithSlash = baseUrlPath; + if (!baseUrlWithSlash.EndsWith("/", StringComparison.Ordinal)) + { + baseUrlWithSlash += "/"; + } + if (string.Equals(urlPath, baseUrlPath, StringComparison.OrdinalIgnoreCase) || + urlPath.StartsWith(baseUrlWithSlash, StringComparison.OrdinalIgnoreCase)) { baseUrlAllowed = true; break; diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs index f508d78a9c3c..19f6d5de45d9 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs @@ -2085,6 +2085,152 @@ public async Task ItShouldAllowCustomSchemesWhenConfiguredAsync() await sut.RunAsync(operation, []); } + [Fact] + public async Task ItShouldBlockRequestWithPrefixCollisionOnAllowedBaseUrlAsync() + { + // Arrange - attacker URL shares prefix with allowed base URL but diverges at path boundary + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com/v1-evil")], + path: "/steal-data", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedBaseUrls = [new Uri("https://api.example.com/v1")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should be blocked because /v1-evil is not under /v1/ + var exception = await Assert.ThrowsAsync(() => sut.RunAsync(operation, [])); + Assert.Contains("not allowed", exception.Message); + Assert.Contains("does not match", exception.Message); + } + + [Fact] + public async Task ItShouldAllowRequestUnderAllowedBaseUrlWithPathAsync() + { + // Arrange - legitimate sub-path under allowed base URL + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com/v1")], + path: "/users", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedBaseUrls = [new Uri("https://api.example.com/v1")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw; /v1/users is under /v1/ + await sut.RunAsync(operation, []); + } + + [Fact] + public async Task ItShouldAllowRequestWhenAllowedBaseUrlContainsQueryOrFragmentAsync() + { + // Arrange - base URL misconfigured with query string; validation should ignore it + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com")], + path: "/users", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedBaseUrls = [new Uri("https://api.example.com?x=1")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw; query/fragment in base URL is stripped for comparison + await sut.RunAsync(operation, []); + } + + [Fact] + public async Task ItShouldBlockHostLevelPrefixCollisionAsync() + { + // Arrange - malicious host shares textual prefix with allowed host + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com.evil.com")], + path: "/steal", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedBaseUrls = [new Uri("https://api.example.com")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should be blocked; api.example.com.evil.com is not api.example.com + var exception = await Assert.ThrowsAsync(() => sut.RunAsync(operation, [])); + Assert.Contains("not allowed", exception.Message); + } + + [Fact] + public async Task ItShouldAllowRequestToBaseUrlPathWithQueryParametersAsync() + { + // Arrange - request to exact base URL path but with query parameters + var queryParameter = new RestApiParameter( + "user", + "string", + isRequired: true, + false, + RestApiParameterLocation.Query, + RestApiParameterStyle.Form); + + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com/v1")], + path: "/", + method: HttpMethod.Get, + description: "test operation", + parameters: [queryParameter], + responses: new Dictionary(), + securityRequirements: [] + ); + + var arguments = new KernelArguments + { + { "user", "1" }, + }; + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedBaseUrls = [new Uri("https://api.example.com/v1")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw; the path portion matches the allowed base URL + await sut.RunAsync(operation, arguments); + } + /// /// Disposes resources used by this class. ///