.Net: Add server URL validation options for OpenAPI plugins (#13631)

### 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\<string\>?`) — restricts which URI
schemes are permitted. When null/empty, defaults to `https` only.
- **AllowedBaseUrls** (`IReadOnlyList\<Uri\>?`) — 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 <[email protected]>
This commit is contained in:
SergeyMenshykh
2026-03-06 18:49:24 +00:00
committed by GitHub
co-authored by Copilot
parent 86d7a49843
commit 4d67592957
5 changed files with 293 additions and 2 deletions
@@ -98,6 +98,15 @@ public class OpenApiFunctionExecutionParameters
/// </summary>
public RestApiParameterFilter? ParameterFilter { get; set; }
/// <summary>
/// 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.
/// </summary>
[Experimental("SKEXP0040")]
public RestApiOperationServerUrlValidationOptions? ServerUrlValidationOptions { get; set; }
/// <summary>
/// The <see cref="ILoggerFactory"/> to use for logging. If null, no logging will be performed.
/// </summary>
@@ -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<KernelFunction>();
ILogger logger = loggerFactory.CreateLogger(typeof(OpenApiKernelExtensions)) ?? NullLogger.Instance;
@@ -115,6 +115,16 @@ internal sealed class RestApiOperationRunner
/// </summary>
private readonly RestApiOperationPayloadFactory? _payloadFactory;
/// <summary>
/// Options for validating server URLs before making HTTP requests.
/// </summary>
private readonly RestApiOperationServerUrlValidationOptions? _serverUrlValidationOptions;
/// <summary>
/// Default allowed schemes when none are explicitly configured.
/// </summary>
private static readonly IReadOnlyList<string> s_defaultAllowedSchemes = ["https"];
/// <summary>
/// Creates an instance of the <see cref="RestApiOperationRunner"/> class.
/// </summary>
@@ -131,6 +141,7 @@ internal sealed class RestApiOperationRunner
/// <param name="urlFactory">The external URL factory to use if provided if provided instead of the default one.</param>
/// <param name="headersFactory">The external headers factory to use if provided instead of the default one.</param>
/// <param name="payloadFactory">The external payload factory to use if provided instead of the default one.</param>
/// <param name="serverUrlValidationOptions">Options for validating server URLs before making HTTP requests.</param>
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
/// <summary>
/// Validates the resolved URL against the configured server URL validation options.
/// </summary>
/// <param name="url">The resolved URL to validate.</param>
/// <exception cref="InvalidOperationException">Thrown when the URL violates the validation rules.</exception>
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.");
}
}
}
/// <summary>
/// Sends an HTTP request.
/// </summary>
@@ -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;
/// <summary>
/// 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.
/// </summary>
[Experimental("SKEXP0040")]
public class RestApiOperationServerUrlValidationOptions
{
/// <summary>
/// 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 <c>AllowedBaseUrls</c> contains <c>https://api.example.com</c>,
/// then requests to <c>https://api.example.com/v1/users</c> will be allowed,
/// but requests to <c>https://evil.com/data</c> will be blocked.
/// If null, no base URL restriction is applied (scheme validation still applies).
/// </summary>
public IReadOnlyList<Uri>? AllowedBaseUrls { get; set; }
/// <summary>
/// Gets or sets the allowed URI schemes.
/// If null or empty, only <c>https</c> is permitted.
/// </summary>
public IReadOnlyList<string>? AllowedSchemes { get; set; }
}
@@ -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<string, RestApiExpectedResponse>(),
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<string, RestApiExpectedResponse>(),
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<InvalidOperationException>(() => 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<string, RestApiExpectedResponse>(),
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<string, RestApiExpectedResponse>(),
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<InvalidOperationException>(() => 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<string, RestApiExpectedResponse>(),
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<string, RestApiExpectedResponse>(),
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<InvalidOperationException>(() => 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<string, RestApiExpectedResponse>(),
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, []);
}
/// <summary>
/// Disposes resources used by this class.
/// </summary>