Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;

namespace Microsoft.SemanticKernel.Plugins.Grpc;

/// <summary>
/// gRPC function execution parameters.
/// </summary>
[Experimental("SKEXP0040")]
public class GrpcFunctionExecutionParameters
{
/// <summary>
/// HttpClient to use for sending gRPC requests.
/// </summary>
public HttpClient? HttpClient { get; set; }

/// <summary>
/// Developer-provided address override for the gRPC channel.
/// When set, this address is used instead of the one from the .proto document.
/// This value is controlled by the developer, not by the LLM.
/// </summary>
public Uri? AddressOverride { get; set; }

/// <summary>
/// Gets or sets the allowed gRPC server base addresses.
/// If set, only requests to addresses that start with one of these base URIs will be permitted.
/// This helps prevent Server-Side Request Forgery (SSRF) attacks.
/// If null, no base address restriction is applied (scheme validation still applies).
/// </summary>
public IReadOnlyList<Uri>? AllowedAddresses { get; set; }

/// <summary>
/// Gets or sets the allowed URI schemes for gRPC server addresses.
/// If null or empty, only <c>https</c> is permitted.
/// </summary>
public IReadOnlyList<string>? AllowedSchemes { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ public static class GrpcKernelExtensions
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="parentDirectory">Directory containing the plugin directory.</param>
/// <param name="pluginDirectoryName">Name of the directory containing the selected plugin.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin ImportPluginFromGrpcDirectory(
this Kernel kernel,
string parentDirectory,
string pluginDirectoryName)
string pluginDirectoryName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName);
KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName, executionParameters);
kernel.Plugins.Add(plugin);
return plugin;
}
Expand All @@ -46,13 +48,15 @@ public static KernelPlugin ImportPluginFromGrpcDirectory(
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="filePath">File path to .proto document.</param>
/// <param name="pluginName">Name of the plugin to register.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin ImportPluginFromGrpcFile(
this Kernel kernel,
string filePath,
string pluginName)
string pluginName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName);
KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName, executionParameters);
kernel.Plugins.Add(plugin);
return plugin;
}
Expand All @@ -63,13 +67,15 @@ public static KernelPlugin ImportPluginFromGrpcFile(
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="documentStream">.proto document stream.</param>
/// <param name="pluginName">Plugin name.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin ImportPluginFromGrpc(
this Kernel kernel,
Stream documentStream,
string pluginName)
string pluginName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName);
KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName, executionParameters);
kernel.Plugins.Add(plugin);
return plugin;
}
Expand All @@ -80,11 +86,13 @@ public static KernelPlugin ImportPluginFromGrpc(
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="parentDirectory">Directory containing the plugin directory.</param>
/// <param name="pluginDirectoryName">Name of the directory containing the selected plugin.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin CreatePluginFromGrpcDirectory(
this Kernel kernel,
string parentDirectory,
string pluginDirectoryName)
string pluginDirectoryName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
const string ProtoFile = "grpc.proto";

Expand All @@ -107,7 +115,7 @@ public static KernelPlugin CreatePluginFromGrpcDirectory(

using var stream = File.OpenRead(filePath);

return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName);
return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName, executionParameters);
}

/// <summary>
Expand All @@ -116,11 +124,13 @@ public static KernelPlugin CreatePluginFromGrpcDirectory(
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="filePath">File path to .proto document.</param>
/// <param name="pluginName">Name of the plugin to register.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin CreatePluginFromGrpcFile(
this Kernel kernel,
string filePath,
string pluginName)
string pluginName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
if (!File.Exists(filePath))
{
Expand All @@ -135,7 +145,7 @@ public static KernelPlugin CreatePluginFromGrpcFile(

using var stream = File.OpenRead(filePath);

return kernel.CreatePluginFromGrpc(stream, pluginName);
return kernel.CreatePluginFromGrpc(stream, pluginName, executionParameters);
}

/// <summary>
Expand All @@ -144,11 +154,13 @@ public static KernelPlugin CreatePluginFromGrpcFile(
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
/// <param name="documentStream">.proto document stream.</param>
/// <param name="pluginName">Plugin name.</param>
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
/// <returns>A list of all the prompt functions representing the plugin.</returns>
public static KernelPlugin CreatePluginFromGrpc(
this Kernel kernel,
Stream documentStream,
string pluginName)
string pluginName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
Verify.NotNull(kernel);
KernelVerify.ValidPluginName(pluginName, kernel.Plugins);
Expand All @@ -162,9 +174,13 @@ public static KernelPlugin CreatePluginFromGrpc(

ILoggerFactory loggerFactory = kernel.LoggerFactory;

using var client = HttpClientProvider.GetHttpClient(kernel.Services.GetService<HttpClient>());
var client = HttpClientProvider.GetHttpClient(executionParameters?.HttpClient ?? kernel.Services.GetService<HttpClient>());

var runner = new GrpcOperationRunner(client);
var runner = new GrpcOperationRunner(
client,
executionParameters?.AddressOverride,
executionParameters?.AllowedAddresses,
executionParameters?.AllowedSchemes);

ILogger logger = loggerFactory.CreateLogger(typeof(GrpcKernelExtensions)) ?? NullLogger.Instance;
foreach (var operation in operations)
Expand Down
96 changes: 90 additions & 6 deletions dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,53 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc;
/// <summary>
/// Runs gRPC operation runner.
/// </summary>
internal sealed class GrpcOperationRunner(HttpClient httpClient)
internal sealed class GrpcOperationRunner
{
/// <summary>Serialization options that use a camel casing naming policy.</summary>
private static readonly JsonSerializerOptions s_camelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
/// <summary>Deserialization options that use case-insensitive property names.</summary>
private static readonly JsonSerializerOptions s_propertyCaseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true };

private static readonly IReadOnlyList<string> s_defaultAllowedSchemes = ["https"];

/// <summary>
/// An instance of the HttpClient class.
/// </summary>
private readonly HttpClient _httpClient = httpClient;
private readonly HttpClient _httpClient;

/// <summary>
/// Developer-provided address override for the gRPC channel.
/// </summary>
private readonly Uri? _addressOverride;

/// <summary>
/// Allowed gRPC server base addresses for SSRF protection.
/// </summary>
private readonly IReadOnlyList<Uri>? _allowedAddresses;

/// <summary>
/// Allowed URI schemes for gRPC server addresses.
/// </summary>
private readonly IReadOnlyList<string> _allowedSchemes;

/// <summary>
/// Creates an instance of a <see cref="GrpcOperationRunner"/> class.
/// </summary>
/// <param name="httpClient">The HttpClient to use for sending gRPC requests.</param>
/// <param name="addressOverride">Optional developer-provided address override.</param>
/// <param name="allowedAddresses">Optional allowed base addresses for SSRF protection.</param>
/// <param name="allowedSchemes">Optional allowed URI schemes. Defaults to https only.</param>
internal GrpcOperationRunner(
HttpClient httpClient,
Uri? addressOverride = null,
IReadOnlyList<Uri>? allowedAddresses = null,
IReadOnlyList<string>? allowedSchemes = null)
{
this._httpClient = httpClient;
this._addressOverride = addressOverride;
this._allowedAddresses = allowedAddresses;
this._allowedSchemes = allowedSchemes is { Count: > 0 } ? allowedSchemes : s_defaultAllowedSchemes;
}

/// <summary>
/// Runs a gRPC operation.
Expand All @@ -47,7 +84,7 @@ public async Task<JsonObject> RunAsync(GrpcOperation operation, KernelArguments

var stringArgument = CastToStringArguments(arguments, operation);

var address = this.GetAddress(operation, stringArgument);
var address = this.GetAddress(operation);

var channelOptions = new GrpcChannelOptions { HttpClient = this._httpClient, DisposeHttpClient = false };

Expand Down Expand Up @@ -118,11 +155,16 @@ private static JsonObject ConvertResponse(object response, Type responseType)
/// Returns address of a channel that provides connection to a gRPC server.
/// </summary>
/// <param name="operation">The gRPC operation.</param>
/// <param name="arguments">The gRPC operation arguments.</param>
/// <returns>The channel address.</returns>
private string GetAddress(GrpcOperation operation, Dictionary<string, string> arguments)
private string GetAddress(GrpcOperation operation)
{
if (!arguments.TryGetValue(GrpcOperation.AddressArgumentName, out string? address))
string? address;

if (this._addressOverride is not null)
{
address = this._addressOverride.AbsoluteUri;
}
else
{
address = operation.Address;
}
Expand All @@ -132,6 +174,48 @@ private string GetAddress(GrpcOperation operation, Dictionary<string, string> ar
throw new KernelException($"No address provided for the '{operation.Name}' gRPC operation.");
}

if (!Uri.TryCreate(address, UriKind.Absolute, out var addressUri))
{
throw new KernelException($"The address '{address}' for the '{operation.Name}' gRPC operation is not a valid absolute URI.");
}

// Validate scheme
if (!this._allowedSchemes.Contains(addressUri.Scheme, StringComparer.OrdinalIgnoreCase))
{
throw new KernelException($"The URI scheme '{addressUri.Scheme}' is not allowed for the '{operation.Name}' gRPC operation. Allowed schemes: {string.Join(", ", this._allowedSchemes)}.");
}

// Validate against allowed addresses
if (this._allowedAddresses is { Count: > 0 })
{
bool isAllowed = false;
foreach (var allowedAddress in this._allowedAddresses)
{
string allowedUri = allowedAddress.AbsoluteUri;

if (addressUri.AbsoluteUri.StartsWith(allowedUri, StringComparison.OrdinalIgnoreCase))
{
// If the allowed URI already ends at a boundary (e.g., trailing '/'),
// or the full URIs match exactly, no further check is needed.
// Otherwise, ensure the next character is a path boundary to prevent
// prefix bypasses (e.g., allowed "https://host/grpc" should not match "https://host/grpcevil").
int prefixLength = allowedUri.Length;
if (prefixLength >= addressUri.AbsoluteUri.Length ||
allowedUri[prefixLength - 1] is '/' ||
addressUri.AbsoluteUri[prefixLength] is '/' or '?' or '#')
{
isAllowed = true;
break;
}
}
}

if (!isAllowed)
{
throw new KernelException($"The address '{address}' is not allowed for the '{operation.Name}' gRPC operation. The address must match one of the allowed base addresses.");
}
}

return address!;
}

Expand Down
11 changes: 0 additions & 11 deletions dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc.Model;
/// </summary>
internal sealed class GrpcOperation
{
/// <summary>
/// Name of 'address' argument used as override for the address provided by gRPC operation.
/// </summary>
internal const string AddressArgumentName = "address";

/// <summary>
/// Name of 'payload' argument that represents gRPC operation request message.
/// </summary>
Expand Down Expand Up @@ -90,12 +85,6 @@ public string FullServiceName
/// <returns>The list of parameters.</returns>
internal static List<KernelParameterMetadata> CreateParameters() =>
[
// Register the "address" parameter so that it's possible to override it if needed.
new(GrpcOperation.AddressArgumentName)
{
Description = "Address for gRPC channel to use.",
},

// Register the "payload" parameter to be used as gRPC operation request message.
new(GrpcOperation.PayloadArgumentName)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public GrpcOperationExtensionsTests()
}

[Fact]
public void ThereShouldBeAddressParameter()
public void ThereShouldNotBeAddressParameter()
{
// Act
var parameters = GrpcOperation.CreateParameters();
Expand All @@ -34,8 +34,7 @@ public void ThereShouldBeAddressParameter()
Assert.NotEmpty(parameters);

var addressParameter = parameters.SingleOrDefault(p => p.Name == "address");
Assert.NotNull(addressParameter);
Assert.Equal("Address for gRPC channel to use.", addressParameter.Description);
Assert.Null(addressParameter);
}

[Fact]
Expand Down
Loading
Loading