From 4d675929578f8a07dfdff5361533d362f628d571 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 6 Mar 2026 18:49:24 +0000 Subject: [PATCH] .Net: Add server URL validation options for OpenAPI plugins (#13631) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation and Context When loading OpenAPI specifications, the SDK uses the `servers[].url` field to construct HTTP request targets. This PR adds an opt-in mechanism for consumers to validate and restrict which URLs the OpenAPI plugin is allowed to call at runtime. ### Description Introduces `RestApiOperationServerUrlValidationOptions`, a new options class that can be configured via `OpenApiFunctionExecutionParameters.ServerUrlValidationOptions` to control outbound request targets: - **AllowedSchemes** (`IReadOnlyList\?`) — restricts which URI schemes are permitted. When null/empty, defaults to `https` only. - **AllowedBaseUrls** (`IReadOnlyList\?`) — restricts requests to URLs matching one of the specified base URL prefixes. When null, no base URL restriction is applied. Validation is performed in `RestApiOperationRunner` before any HTTP request is sent. When `ServerUrlValidationOptions` is not set (default), behavior is unchanged — no validation is performed. ### Changes - New class: `RestApiOperationServerUrlValidationOptions` - `OpenApiFunctionExecutionParameters`: added `ServerUrlValidationOptions` property (`[Experimental("SKEXP0040")]`) - `RestApiOperationRunner`: added `ValidateUrl()` with scheme and base URL checks - `OpenApiKernelPluginFactory`: wires validation options through to the runner - 7 new unit tests covering scheme blocking, base URL allowlisting, and mixed configurations ### Usage Example ```csharp var plugin = await kernel.ImportPluginFromOpenApiAsync( pluginName: "myApi", filePath: specPath, executionParameters: new OpenApiFunctionExecutionParameters { ServerUrlValidationOptions = new RestApiOperationServerUrlValidationOptions { AllowedBaseUrls = [new Uri("https://api.example.com")], AllowedSchemes = ["https"] } }); ``` ### Contribution Checklist - [x] The code builds clean without any errors or warnings - [x] The PR follows the [SK Contribution Guidelines](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md) and the [pre-submission formatting script](https://github.com/microsoft/semantic-kernel/blob/main/CONTRIBUTING.md#development-scripts) raises no violations - [x] All unit tests pass - [x] New unit tests added --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../OpenApiFunctionExecutionParameters.cs | 9 + .../OpenApiKernelPluginFactory.cs | 3 +- .../RestApiOperationRunner.cs | 74 +++++++- ...tApiOperationServerUrlValidationOptions.cs | 32 ++++ .../OpenApi/RestApiOperationRunnerTests.cs | 177 ++++++++++++++++++ 5 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs diff --git a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs index ec2c4f51c1..31f95a8a53 100644 --- a/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs +++ b/dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs @@ -98,6 +98,15 @@ public class OpenApiFunctionExecutionParameters /// public RestApiParameterFilter? ParameterFilter { get; set; } + /// + /// Options for validating server URLs before making HTTP requests. + /// When set, the plugin will validate each resolved URL against the configured allowed base URLs and schemes + /// before sending the HTTP request. This helps prevent Server-Side Request Forgery (SSRF) attacks. + /// If null (default), no URL validation is performed. + /// + [Experimental("SKEXP0040")] + public RestApiOperationServerUrlValidationOptions? ServerUrlValidationOptions { get; set; } + /// /// The to use for logging. If null, no logging will be performed. /// diff --git a/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs b/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs index 00c81894f9..b9c658363d 100644 --- a/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs +++ b/dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs @@ -214,7 +214,8 @@ public static partial class OpenApiKernelPluginFactory executionParameters?.EnableDynamicPayload ?? true, executionParameters?.EnablePayloadNamespacing ?? false, executionParameters?.HttpResponseContentReader, - executionParameters?.RestApiOperationResponseFactory); + executionParameters?.RestApiOperationResponseFactory, + serverUrlValidationOptions: executionParameters?.ServerUrlValidationOptions); var functions = new List(); ILogger logger = loggerFactory.CreateLogger(typeof(OpenApiKernelExtensions)) ?? NullLogger.Instance; diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs index d469466f90..d5bf54678a 100644 --- a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs @@ -115,6 +115,16 @@ internal sealed class RestApiOperationRunner /// private readonly RestApiOperationPayloadFactory? _payloadFactory; + /// + /// Options for validating server URLs before making HTTP requests. + /// + private readonly RestApiOperationServerUrlValidationOptions? _serverUrlValidationOptions; + + /// + /// Default allowed schemes when none are explicitly configured. + /// + private static readonly IReadOnlyList s_defaultAllowedSchemes = ["https"]; + /// /// Creates an instance of the class. /// @@ -131,6 +141,7 @@ internal sealed class RestApiOperationRunner /// The external URL factory to use if provided if provided instead of the default one. /// The external headers factory to use if provided instead of the default one. /// The external payload factory to use if provided instead of the default one. + /// Options for validating server URLs before making HTTP requests. public RestApiOperationRunner( HttpClient httpClient, AuthenticateRequestAsyncCallback? authCallback = null, @@ -141,7 +152,8 @@ internal sealed class RestApiOperationRunner RestApiOperationResponseFactory? responseFactory = null, RestApiOperationUrlFactory? urlFactory = null, RestApiOperationHeadersFactory? headersFactory = null, - RestApiOperationPayloadFactory? payloadFactory = null) + RestApiOperationPayloadFactory? payloadFactory = null, + RestApiOperationServerUrlValidationOptions? serverUrlValidationOptions = null) { this._httpClient = httpClient; this._userAgent = userAgent ?? HttpHeaderConstant.Values.UserAgent; @@ -152,6 +164,7 @@ internal sealed class RestApiOperationRunner this._urlFactory = urlFactory; this._headersFactory = headersFactory; this._payloadFactory = payloadFactory; + this._serverUrlValidationOptions = serverUrlValidationOptions; // If no auth callback provided, use empty function if (authCallback is null) @@ -186,6 +199,8 @@ internal sealed class RestApiOperationRunner { var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl); + this.ValidateUrl(url); + var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments); var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments); @@ -195,6 +210,63 @@ internal sealed class RestApiOperationRunner #region private + /// + /// Validates the resolved URL against the configured server URL validation options. + /// + /// The resolved URL to validate. + /// Thrown when the URL violates the validation rules. + private void ValidateUrl(Uri url) + { + if (this._serverUrlValidationOptions is null) + { + return; + } + + // Validate the URI scheme. + var allowedSchemes = this._serverUrlValidationOptions.AllowedSchemes ?? s_defaultAllowedSchemes; + if (allowedSchemes.Count > 0) + { + bool schemeAllowed = false; + foreach (var scheme in allowedSchemes) + { + if (string.Equals(url.Scheme, scheme, StringComparison.OrdinalIgnoreCase)) + { + schemeAllowed = true; + break; + } + } + + if (!schemeAllowed) + { + throw new InvalidOperationException( + $"The request URI scheme '{url.Scheme}' is not allowed. Allowed schemes: {string.Join(", ", allowedSchemes)}."); + } + } + + // Validate the URL against the allowed base URLs. + 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)) + { + baseUrlAllowed = true; + break; + } + } + + if (!baseUrlAllowed) + { + throw new InvalidOperationException( + $"The request URI '{url}' is not allowed. It does not match any of the allowed base URLs."); + } + } + } + /// /// Sends an HTTP request. /// diff --git a/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs new file mode 100644 index 0000000000..0f9335998e --- /dev/null +++ b/dotnet/src/Functions/Functions.OpenApi/RestApiOperationServerUrlValidationOptions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Plugins.OpenApi; + +/// +/// Options for validating server URLs before making HTTP requests in the OpenAPI plugin. +/// When configured, these options help prevent Server-Side Request Forgery (SSRF) attacks +/// by restricting which URLs the plugin is allowed to call. +/// +[Experimental("SKEXP0040")] +public class RestApiOperationServerUrlValidationOptions +{ + /// + /// Gets or sets the allowed base URLs. + /// If set, only requests to URLs that start with one of these base URLs will be permitted. + /// For example, if AllowedBaseUrls contains https://api.example.com, + /// then requests to https://api.example.com/v1/users will be allowed, + /// but requests to https://evil.com/data will be blocked. + /// If null, no base URL restriction is applied (scheme validation still applies). + /// + public IReadOnlyList? AllowedBaseUrls { get; set; } + + /// + /// Gets or sets the allowed URI schemes. + /// If null or empty, only https is permitted. + /// + public IReadOnlyList? AllowedSchemes { get; set; } +} diff --git a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs index 6ca83fa986..f508d78a9c 100644 --- a/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs +++ b/dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs @@ -1908,6 +1908,183 @@ public sealed class RestApiOperationRunnerTests : IDisposable Assert.Same(httpResponseStream, response.Content); } + [Fact] + public async Task ItShouldAllowRequestWhenNoValidationOptionsConfiguredAsync() + { + // Arrange - no validation options (default behavior) + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("http://internal-service:8080")], + path: "/api/data", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object); + + // Act & Assert - should not throw + await sut.RunAsync(operation, []); + } + + [Fact] + public async Task ItShouldBlockRequestWithDisallowedSchemeAsync() + { + // Arrange + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("http://api.example.com")], + path: "/api/data", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions(); + // Default AllowedSchemes is ["https"], so "http" should be blocked. + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => sut.RunAsync(operation, [])); + Assert.Contains("http", exception.Message); + Assert.Contains("not allowed", exception.Message); + } + + [Fact] + public async Task ItShouldAllowRequestWithAllowedSchemeAsync() + { + // Arrange + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://api.example.com")], + path: "/api/data", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions(); + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw + await sut.RunAsync(operation, []); + } + + [Fact] + public async Task ItShouldBlockRequestNotMatchingAllowedBaseUrlsAsync() + { + // Arrange + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://evil.com")], + 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")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert + 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 ItShouldAllowRequestMatchingAllowedBaseUrlsAsync() + { + // Arrange + 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")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw + await sut.RunAsync(operation, []); + } + + [Fact] + public async Task ItShouldBlockCloudMetadataEndpointAsync() + { + // Arrange - simulate SSRF targeting cloud metadata + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("https://169.254.169.254")], + path: "/latest/meta-data/", + 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 + await Assert.ThrowsAsync(() => sut.RunAsync(operation, [])); + } + + [Fact] + public async Task ItShouldAllowCustomSchemesWhenConfiguredAsync() + { + // Arrange + var operation = new RestApiOperation( + id: "test", + servers: [new RestApiServer("http://api.example.com")], + path: "/api/data", + method: HttpMethod.Get, + description: "test operation", + parameters: [], + responses: new Dictionary(), + securityRequirements: [] + ); + + var validationOptions = new RestApiOperationServerUrlValidationOptions + { + AllowedSchemes = ["http", "https"], + AllowedBaseUrls = [new Uri("http://api.example.com")] + }; + + var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions); + + // Act & Assert - should not throw + await sut.RunAsync(operation, []); + } + /// /// Disposes resources used by this class. ///