Skip to content
Merged
2 changes: 1 addition & 1 deletion dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@
<PackageVersion Include="PdfPig" Version="0.1.13" />
<PackageVersion Include="Pinecone.Client" Version="3.1.0" />
<PackageVersion Include="Prompty.Core" Version="0.2.3-beta" />
<PackageVersion Include="Scriban" Version="7.1.0" />
<PackageVersion Include="Scriban" Version="7.2.0" />
<PackageVersion Include="PuppeteerSharp" Version="20.2.5" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.2" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes</Target>
<Left>lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})</Target>
<Left>lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/net10.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes</Target>
<Left>lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})</Target>
<Left>lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>F:Microsoft.SemanticKernel.Plugins.OpenApi.OpenApiKernelFunctionContext.KernelFunctionContextKey</Target>
<Left>lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/net8.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.get_AllowedSchemes</Target>
<Left>lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.SemanticKernel.Plugins.OpenApi.RestApiOperationServerUrlValidationOptions.set_AllowedSchemes(System.Collections.Generic.IReadOnlyList{System.String})</Target>
<Left>lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Left>
<Right>lib/netstandard2.0/Microsoft.SemanticKernel.Plugins.OpenApi.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,26 @@ public class OpenApiFunctionExecutionParameters
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.
/// Options for validating server URLs before making HTTP requests, to help prevent
/// Server-Side Request Forgery (SSRF) attacks against private/internal infrastructure.
/// </summary>
/// <remarks>
/// <para>
/// Validation is <b>on by default</b>: when this property is <c>null</c>, the plugin behaves
/// as if a default-constructed <see cref="RestApiOperationServerUrlValidationOptions"/> was
/// supplied. The implicit policy permits only HTTPS URLs that resolve to public IP
/// addresses, blocking loopback, link-local, RFC1918, IPv6 ULA, CGNAT and other
/// non-public ranges (including the cloud-metadata address <c>169.254.169.254</c>).
/// </para>
/// <para>
/// To allow plaintext HTTP or private/loopback hosts (for example for localhost
/// development or on-prem APIs), set
/// <see cref="RestApiOperationServerUrlValidationOptions.AllowedBaseUrls"/> with the
/// specific allowed origins, or set
/// <see cref="RestApiOperationServerUrlValidationOptions.AllowPrivateNetworkAccess"/>
/// to <c>true</c>.
/// </para>
/// </remarks>
[Experimental("SKEXP0040")]
public RestApiOperationServerUrlValidationOptions? ServerUrlValidationOptions { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,6 @@ internal sealed class RestApiOperationRunner
/// </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>
Expand Down Expand Up @@ -191,89 +186,25 @@ public RestApiOperationRunner(
/// <param name="options">Options for REST API operation run.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The task execution result.</returns>
public Task<RestApiOperationResponse> RunAsync(
public async Task<RestApiOperationResponse> RunAsync(
RestApiOperation operation,
KernelArguments arguments,
RestApiOperationRunOptions? options = null,
CancellationToken cancellationToken = default)
{
var url = this._urlFactory?.Invoke(operation, arguments, options) ?? this.BuildsOperationUrl(operation, arguments, options?.ServerUrlOverride, options?.ApiHostUrl);

this.ValidateUrl(url);
await ServerUrlValidator.ValidateAsync(url, this._serverUrlValidationOptions, cancellationToken).ConfigureAwait(false);

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);

return this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken);
return await this.SendAsync(operation, url, headers, Payload, Content, options, cancellationToken).ConfigureAwait(false);
}

#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;

foreach (var baseUrl in allowedBaseUrls)
{
// 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;
}
}

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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,64 @@ 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.
/// These options control the secure-by-default protection against Server-Side Request Forgery
/// (SSRF) attacks that the OpenAPI plugin applies to URLs derived from the OpenAPI document
/// (the <c>servers[].url</c> field and any server-variable substitutions).
/// </summary>
/// <remarks>
/// <para>
/// When <see cref="OpenApiFunctionExecutionParameters.ServerUrlValidationOptions"/> is left
/// <see langword="null"/>, a default-constructed instance of this class is applied. The default
/// policy is:
/// </para>
/// <list type="bullet">
/// <item><description>Only the <c>https</c> scheme is permitted for URLs that are not on the
/// <see cref="AllowedBaseUrls"/> allowlist.</description></item>
/// <item><description>Requests whose host resolves to a loopback, link-local (including the
/// cloud metadata endpoint <c>169.254.169.254</c>), private (RFC1918), IPv6 unique local
/// (<c>fc00::/7</c>), carrier-grade NAT, multicast, or reserved IP range are rejected.</description></item>
/// </list>
/// <para>
/// A URL that matches an entry in <see cref="AllowedBaseUrls"/> is an explicit allow and
/// bypasses both the implicit https-only gate and the private-IP gate, so an allowlist can be
/// used to opt specific intranet hosts back in.
/// </para>
/// <para>
/// <b>Known limitation:</b> URL validation is performed before the HTTP request is sent. If the
/// <see cref="System.Net.Http.HttpClient"/> used to invoke the plugin has automatic redirect
/// following enabled (the default), an attacker that controls a public host may redirect the
/// request to a private address. Configure your <see cref="System.Net.Http.HttpClient"/> with
/// <c>AllowAutoRedirect = false</c> when consuming OpenAPI documents from untrusted sources.
/// </para>
/// </remarks>
[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).
/// Gets or sets the explicit allowlist of base URLs. A request whose final URL matches one
/// of these entries (same scheme, host, port, and path prefix) is permitted regardless of
/// the implicit scheme and private-IP gates.
/// </summary>
/// <remarks>
/// For example, with <c>AllowedBaseUrls = [new Uri("https://api.example.com/v1")]</c>:
/// <c>https://api.example.com/v1/users</c> is allowed, <c>https://api.example.com/v2/...</c>
/// is rejected, and <c>https://evil.com/...</c> is rejected. Query strings and fragments in
/// the entries are ignored for comparison. Adding an <c>http://</c> entry (for example,
/// <c>http://localhost:5000</c> or <c>http://intranet.corp</c>) is the recommended way to
/// opt-in specific plaintext or intranet endpoints without weakening the global defaults.
/// </remarks>
public IReadOnlyList<Uri>? AllowedBaseUrls { get; set; }

/// <summary>
/// Gets or sets the allowed URI schemes.
/// If null or empty, only <c>https</c> is permitted.
/// Gets or sets a value indicating whether requests to private, loopback, link-local, and
/// other non-public IP ranges are permitted for URLs that are not covered by
/// <see cref="AllowedBaseUrls"/>. The default is <see langword="false"/> (secure).
/// </summary>
public IReadOnlyList<string>? AllowedSchemes { get; set; }
/// <remarks>
/// Setting this to <see langword="true"/> disables the SSRF protections that block requests
/// to cloud metadata services (e.g. <c>169.254.169.254</c>), <c>localhost</c>, RFC1918
/// networks, and similar ranges. Only enable this in trusted environments (such as local
/// development). Prefer adding specific hosts to <see cref="AllowedBaseUrls"/> instead.
/// </remarks>
public bool AllowPrivateNetworkAccess { get; set; }
}
Loading
Loading