mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
.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:
co-authored by
Copilot
parent
86d7a49843
commit
4d67592957
+9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user