Skip to content

Commit 4d67592

Browse files
.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 <223556219+Copilot@users.noreply.github.com>
1 parent 86d7a49 commit 4d67592

File tree

5 files changed

+293
-2
lines changed

5 files changed

+293
-2
lines changed

dotnet/src/Functions/Functions.OpenApi/Extensions/OpenApiFunctionExecutionParameters.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ public class OpenApiFunctionExecutionParameters
9898
/// </summary>
9999
public RestApiParameterFilter? ParameterFilter { get; set; }
100100

101+
/// <summary>
102+
/// Options for validating server URLs before making HTTP requests.
103+
/// When set, the plugin will validate each resolved URL against the configured allowed base URLs and schemes
104+
/// before sending the HTTP request. This helps prevent Server-Side Request Forgery (SSRF) attacks.
105+
/// If null (default), no URL validation is performed.
106+
/// </summary>
107+
[Experimental("SKEXP0040")]
108+
public RestApiOperationServerUrlValidationOptions? ServerUrlValidationOptions { get; set; }
109+
101110
/// <summary>
102111
/// The <see cref="ILoggerFactory"/> to use for logging. If null, no logging will be performed.
103112
/// </summary>

dotnet/src/Functions/Functions.OpenApi/OpenApiKernelPluginFactory.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,8 @@ internal static KernelPlugin CreateOpenApiPlugin(
214214
executionParameters?.EnableDynamicPayload ?? true,
215215
executionParameters?.EnablePayloadNamespacing ?? false,
216216
executionParameters?.HttpResponseContentReader,
217-
executionParameters?.RestApiOperationResponseFactory);
217+
executionParameters?.RestApiOperationResponseFactory,
218+
serverUrlValidationOptions: executionParameters?.ServerUrlValidationOptions);
218219

219220
var functions = new List<KernelFunction>();
220221
ILogger logger = loggerFactory.CreateLogger(typeof(OpenApiKernelExtensions)) ?? NullLogger.Instance;

dotnet/src/Functions/Functions.OpenApi/RestApiOperationRunner.cs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ internal sealed class RestApiOperationRunner
115115
/// </summary>
116116
private readonly RestApiOperationPayloadFactory? _payloadFactory;
117117

118+
/// <summary>
119+
/// Options for validating server URLs before making HTTP requests.
120+
/// </summary>
121+
private readonly RestApiOperationServerUrlValidationOptions? _serverUrlValidationOptions;
122+
123+
/// <summary>
124+
/// Default allowed schemes when none are explicitly configured.
125+
/// </summary>
126+
private static readonly IReadOnlyList<string> s_defaultAllowedSchemes = ["https"];
127+
118128
/// <summary>
119129
/// Creates an instance of the <see cref="RestApiOperationRunner"/> class.
120130
/// </summary>
@@ -131,6 +141,7 @@ internal sealed class RestApiOperationRunner
131141
/// <param name="urlFactory">The external URL factory to use if provided if provided instead of the default one.</param>
132142
/// <param name="headersFactory">The external headers factory to use if provided instead of the default one.</param>
133143
/// <param name="payloadFactory">The external payload factory to use if provided instead of the default one.</param>
144+
/// <param name="serverUrlValidationOptions">Options for validating server URLs before making HTTP requests.</param>
134145
public RestApiOperationRunner(
135146
HttpClient httpClient,
136147
AuthenticateRequestAsyncCallback? authCallback = null,
@@ -141,7 +152,8 @@ public RestApiOperationRunner(
141152
RestApiOperationResponseFactory? responseFactory = null,
142153
RestApiOperationUrlFactory? urlFactory = null,
143154
RestApiOperationHeadersFactory? headersFactory = null,
144-
RestApiOperationPayloadFactory? payloadFactory = null)
155+
RestApiOperationPayloadFactory? payloadFactory = null,
156+
RestApiOperationServerUrlValidationOptions? serverUrlValidationOptions = null)
145157
{
146158
this._httpClient = httpClient;
147159
this._userAgent = userAgent ?? HttpHeaderConstant.Values.UserAgent;
@@ -152,6 +164,7 @@ public RestApiOperationRunner(
152164
this._urlFactory = urlFactory;
153165
this._headersFactory = headersFactory;
154166
this._payloadFactory = payloadFactory;
167+
this._serverUrlValidationOptions = serverUrlValidationOptions;
155168

156169
// If no auth callback provided, use empty function
157170
if (authCallback is null)
@@ -186,6 +199,8 @@ public Task<RestApiOperationResponse> RunAsync(
186199
{
187200
var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl);
188201

202+
this.ValidateUrl(url);
203+
189204
var headers = this._headersFactory?.Invoke(operation, arguments, options) ?? operation.BuildHeaders(arguments);
190205

191206
var (Payload, Content) = this._payloadFactory?.Invoke(operation, arguments, this._enableDynamicPayload, this._enablePayloadNamespacing, options) ?? this.BuildOperationPayload(operation, arguments);
@@ -195,6 +210,63 @@ public Task<RestApiOperationResponse> RunAsync(
195210

196211
#region private
197212

213+
/// <summary>
214+
/// Validates the resolved URL against the configured server URL validation options.
215+
/// </summary>
216+
/// <param name="url">The resolved URL to validate.</param>
217+
/// <exception cref="InvalidOperationException">Thrown when the URL violates the validation rules.</exception>
218+
private void ValidateUrl(Uri url)
219+
{
220+
if (this._serverUrlValidationOptions is null)
221+
{
222+
return;
223+
}
224+
225+
// Validate the URI scheme.
226+
var allowedSchemes = this._serverUrlValidationOptions.AllowedSchemes ?? s_defaultAllowedSchemes;
227+
if (allowedSchemes.Count > 0)
228+
{
229+
bool schemeAllowed = false;
230+
foreach (var scheme in allowedSchemes)
231+
{
232+
if (string.Equals(url.Scheme, scheme, StringComparison.OrdinalIgnoreCase))
233+
{
234+
schemeAllowed = true;
235+
break;
236+
}
237+
}
238+
239+
if (!schemeAllowed)
240+
{
241+
throw new InvalidOperationException(
242+
$"The request URI scheme '{url.Scheme}' is not allowed. Allowed schemes: {string.Join(", ", allowedSchemes)}.");
243+
}
244+
}
245+
246+
// Validate the URL against the allowed base URLs.
247+
if (this._serverUrlValidationOptions.AllowedBaseUrls is { Count: > 0 } allowedBaseUrls)
248+
{
249+
bool baseUrlAllowed = false;
250+
var urlString = url.AbsoluteUri;
251+
252+
foreach (var baseUrl in allowedBaseUrls)
253+
{
254+
var baseUrlString = baseUrl.AbsoluteUri;
255+
if (urlString.StartsWith(baseUrlString, StringComparison.OrdinalIgnoreCase))
256+
{
257+
baseUrlAllowed = true;
258+
break;
259+
}
260+
}
261+
262+
if (!baseUrlAllowed)
263+
{
264+
throw new InvalidOperationException(
265+
$"The request URI '{url}' is not allowed. It does not match any of the allowed base URLs.");
266+
}
267+
}
268+
}
269+
198270
/// <summary>
199271
/// Sends an HTTP request.
200272
/// </summary>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
7+
namespace Microsoft.SemanticKernel.Plugins.OpenApi;
8+
9+
/// <summary>
10+
/// Options for validating server URLs before making HTTP requests in the OpenAPI plugin.
11+
/// When configured, these options help prevent Server-Side Request Forgery (SSRF) attacks
12+
/// by restricting which URLs the plugin is allowed to call.
13+
/// </summary>
14+
[Experimental("SKEXP0040")]
15+
public class RestApiOperationServerUrlValidationOptions
16+
{
17+
/// <summary>
18+
/// Gets or sets the allowed base URLs.
19+
/// If set, only requests to URLs that start with one of these base URLs will be permitted.
20+
/// For example, if <c>AllowedBaseUrls</c> contains <c>https://api.example.com</c>,
21+
/// then requests to <c>https://api.example.com/v1/users</c> will be allowed,
22+
/// but requests to <c>https://evil.com/data</c> will be blocked.
23+
/// If null, no base URL restriction is applied (scheme validation still applies).
24+
/// </summary>
25+
public IReadOnlyList<Uri>? AllowedBaseUrls { get; set; }
26+
27+
/// <summary>
28+
/// Gets or sets the allowed URI schemes.
29+
/// If null or empty, only <c>https</c> is permitted.
30+
/// </summary>
31+
public IReadOnlyList<string>? AllowedSchemes { get; set; }
32+
}

dotnet/src/Functions/Functions.UnitTests/OpenApi/RestApiOperationRunnerTests.cs

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,183 @@ async Task<RestApiOperationResponse> RestApiOperationResponseFactory(RestApiOper
19081908
Assert.Same(httpResponseStream, response.Content);
19091909
}
19101910

1911+
[Fact]
1912+
public async Task ItShouldAllowRequestWhenNoValidationOptionsConfiguredAsync()
1913+
{
1914+
// Arrange - no validation options (default behavior)
1915+
var operation = new RestApiOperation(
1916+
id: "test",
1917+
servers: [new RestApiServer("http://internal-service:8080")],
1918+
path: "/api/data",
1919+
method: HttpMethod.Get,
1920+
description: "test operation",
1921+
parameters: [],
1922+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1923+
securityRequirements: []
1924+
);
1925+
1926+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object);
1927+
1928+
// Act & Assert - should not throw
1929+
await sut.RunAsync(operation, []);
1930+
}
1931+
1932+
[Fact]
1933+
public async Task ItShouldBlockRequestWithDisallowedSchemeAsync()
1934+
{
1935+
// Arrange
1936+
var operation = new RestApiOperation(
1937+
id: "test",
1938+
servers: [new RestApiServer("http://api.example.com")],
1939+
path: "/api/data",
1940+
method: HttpMethod.Get,
1941+
description: "test operation",
1942+
parameters: [],
1943+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1944+
securityRequirements: []
1945+
);
1946+
1947+
var validationOptions = new RestApiOperationServerUrlValidationOptions();
1948+
// Default AllowedSchemes is ["https"], so "http" should be blocked.
1949+
1950+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
1951+
1952+
// Act & Assert
1953+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(operation, []));
1954+
Assert.Contains("http", exception.Message);
1955+
Assert.Contains("not allowed", exception.Message);
1956+
}
1957+
1958+
[Fact]
1959+
public async Task ItShouldAllowRequestWithAllowedSchemeAsync()
1960+
{
1961+
// Arrange
1962+
var operation = new RestApiOperation(
1963+
id: "test",
1964+
servers: [new RestApiServer("https://api.example.com")],
1965+
path: "/api/data",
1966+
method: HttpMethod.Get,
1967+
description: "test operation",
1968+
parameters: [],
1969+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1970+
securityRequirements: []
1971+
);
1972+
1973+
var validationOptions = new RestApiOperationServerUrlValidationOptions();
1974+
1975+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
1976+
1977+
// Act & Assert - should not throw
1978+
await sut.RunAsync(operation, []);
1979+
}
1980+
1981+
[Fact]
1982+
public async Task ItShouldBlockRequestNotMatchingAllowedBaseUrlsAsync()
1983+
{
1984+
// Arrange
1985+
var operation = new RestApiOperation(
1986+
id: "test",
1987+
servers: [new RestApiServer("https://evil.com")],
1988+
path: "/steal-data",
1989+
method: HttpMethod.Get,
1990+
description: "test operation",
1991+
parameters: [],
1992+
responses: new Dictionary<string, RestApiExpectedResponse>(),
1993+
securityRequirements: []
1994+
);
1995+
1996+
var validationOptions = new RestApiOperationServerUrlValidationOptions
1997+
{
1998+
AllowedBaseUrls = [new Uri("https://api.example.com")]
1999+
};
2000+
2001+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2002+
2003+
// Act & Assert
2004+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(operation, []));
2005+
Assert.Contains("not allowed", exception.Message);
2006+
Assert.Contains("does not match", exception.Message);
2007+
}
2008+
2009+
[Fact]
2010+
public async Task ItShouldAllowRequestMatchingAllowedBaseUrlsAsync()
2011+
{
2012+
// Arrange
2013+
var operation = new RestApiOperation(
2014+
id: "test",
2015+
servers: [new RestApiServer("https://api.example.com")],
2016+
path: "/users",
2017+
method: HttpMethod.Get,
2018+
description: "test operation",
2019+
parameters: [],
2020+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2021+
securityRequirements: []
2022+
);
2023+
2024+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2025+
{
2026+
AllowedBaseUrls = [new Uri("https://api.example.com")]
2027+
};
2028+
2029+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2030+
2031+
// Act & Assert - should not throw
2032+
await sut.RunAsync(operation, []);
2033+
}
2034+
2035+
[Fact]
2036+
public async Task ItShouldBlockCloudMetadataEndpointAsync()
2037+
{
2038+
// Arrange - simulate SSRF targeting cloud metadata
2039+
var operation = new RestApiOperation(
2040+
id: "test",
2041+
servers: [new RestApiServer("https://169.254.169.254")],
2042+
path: "/latest/meta-data/",
2043+
method: HttpMethod.Get,
2044+
description: "test operation",
2045+
parameters: [],
2046+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2047+
securityRequirements: []
2048+
);
2049+
2050+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2051+
{
2052+
AllowedBaseUrls = [new Uri("https://api.example.com")]
2053+
};
2054+
2055+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2056+
2057+
// Act & Assert
2058+
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.RunAsync(operation, []));
2059+
}
2060+
2061+
[Fact]
2062+
public async Task ItShouldAllowCustomSchemesWhenConfiguredAsync()
2063+
{
2064+
// Arrange
2065+
var operation = new RestApiOperation(
2066+
id: "test",
2067+
servers: [new RestApiServer("http://api.example.com")],
2068+
path: "/api/data",
2069+
method: HttpMethod.Get,
2070+
description: "test operation",
2071+
parameters: [],
2072+
responses: new Dictionary<string, RestApiExpectedResponse>(),
2073+
securityRequirements: []
2074+
);
2075+
2076+
var validationOptions = new RestApiOperationServerUrlValidationOptions
2077+
{
2078+
AllowedSchemes = ["http", "https"],
2079+
AllowedBaseUrls = [new Uri("http://api.example.com")]
2080+
};
2081+
2082+
var sut = new RestApiOperationRunner(this._httpClient, this._authenticationHandlerMock.Object, serverUrlValidationOptions: validationOptions);
2083+
2084+
// Act & Assert - should not throw
2085+
await sut.RunAsync(operation, []);
2086+
}
2087+
19112088
/// <summary>
19122089
/// Disposes resources used by this class.
19132090
/// </summary>

0 commit comments

Comments
 (0)