diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs
new file mode 100644
index 000000000000..16a23f6100bb
--- /dev/null
+++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcFunctionExecutionParameters.cs
@@ -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;
+
+///
+/// gRPC function execution parameters.
+///
+[Experimental("SKEXP0040")]
+public class GrpcFunctionExecutionParameters
+{
+ ///
+ /// HttpClient to use for sending gRPC requests.
+ ///
+ public HttpClient? HttpClient { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public Uri? AddressOverride { get; set; }
+
+ ///
+ /// 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).
+ ///
+ public IReadOnlyList? AllowedAddresses { get; set; }
+
+ ///
+ /// Gets or sets the allowed URI schemes for gRPC server addresses.
+ /// If null or empty, only https is permitted.
+ ///
+ public IReadOnlyList? AllowedSchemes { get; set; }
+}
diff --git a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs
index 2b2b7488db6a..6d7331b05f90 100644
--- a/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs
+++ b/dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs
@@ -29,13 +29,15 @@ public static class GrpcKernelExtensions
/// The containing services, plugins, and other state for use throughout the operation.
/// Directory containing the plugin directory.
/// Name of the directory containing the selected plugin.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
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;
}
@@ -46,13 +48,15 @@ public static KernelPlugin ImportPluginFromGrpcDirectory(
/// The containing services, plugins, and other state for use throughout the operation.
/// File path to .proto document.
/// Name of the plugin to register.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
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;
}
@@ -63,13 +67,15 @@ public static KernelPlugin ImportPluginFromGrpcFile(
/// The containing services, plugins, and other state for use throughout the operation.
/// .proto document stream.
/// Plugin name.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
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;
}
@@ -80,11 +86,13 @@ public static KernelPlugin ImportPluginFromGrpc(
/// The containing services, plugins, and other state for use throughout the operation.
/// Directory containing the plugin directory.
/// Name of the directory containing the selected plugin.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
public static KernelPlugin CreatePluginFromGrpcDirectory(
this Kernel kernel,
string parentDirectory,
- string pluginDirectoryName)
+ string pluginDirectoryName,
+ GrpcFunctionExecutionParameters? executionParameters = null)
{
const string ProtoFile = "grpc.proto";
@@ -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);
}
///
@@ -116,11 +124,13 @@ public static KernelPlugin CreatePluginFromGrpcDirectory(
/// The containing services, plugins, and other state for use throughout the operation.
/// File path to .proto document.
/// Name of the plugin to register.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
public static KernelPlugin CreatePluginFromGrpcFile(
this Kernel kernel,
string filePath,
- string pluginName)
+ string pluginName,
+ GrpcFunctionExecutionParameters? executionParameters = null)
{
if (!File.Exists(filePath))
{
@@ -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);
}
///
@@ -144,11 +154,13 @@ public static KernelPlugin CreatePluginFromGrpcFile(
/// The containing services, plugins, and other state for use throughout the operation.
/// .proto document stream.
/// Plugin name.
+ /// Optional gRPC function execution parameters.
/// A list of all the prompt functions representing the plugin.
public static KernelPlugin CreatePluginFromGrpc(
this Kernel kernel,
Stream documentStream,
- string pluginName)
+ string pluginName,
+ GrpcFunctionExecutionParameters? executionParameters = null)
{
Verify.NotNull(kernel);
KernelVerify.ValidPluginName(pluginName, kernel.Plugins);
@@ -162,9 +174,13 @@ public static KernelPlugin CreatePluginFromGrpc(
ILoggerFactory loggerFactory = kernel.LoggerFactory;
- using var client = HttpClientProvider.GetHttpClient(kernel.Services.GetService());
+ var client = HttpClientProvider.GetHttpClient(executionParameters?.HttpClient ?? kernel.Services.GetService());
- 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)
diff --git a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs
index c4726e649d3d..57e32fac58de 100644
--- a/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs
+++ b/dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs
@@ -22,16 +22,53 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc;
///
/// Runs gRPC operation runner.
///
-internal sealed class GrpcOperationRunner(HttpClient httpClient)
+internal sealed class GrpcOperationRunner
{
/// Serialization options that use a camel casing naming policy.
private static readonly JsonSerializerOptions s_camelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
/// Deserialization options that use case-insensitive property names.
private static readonly JsonSerializerOptions s_propertyCaseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true };
+
+ private static readonly IReadOnlyList s_defaultAllowedSchemes = ["https"];
+
///
/// An instance of the HttpClient class.
///
- private readonly HttpClient _httpClient = httpClient;
+ private readonly HttpClient _httpClient;
+
+ ///
+ /// Developer-provided address override for the gRPC channel.
+ ///
+ private readonly Uri? _addressOverride;
+
+ ///
+ /// Allowed gRPC server base addresses for SSRF protection.
+ ///
+ private readonly IReadOnlyList? _allowedAddresses;
+
+ ///
+ /// Allowed URI schemes for gRPC server addresses.
+ ///
+ private readonly IReadOnlyList _allowedSchemes;
+
+ ///
+ /// Creates an instance of a class.
+ ///
+ /// The HttpClient to use for sending gRPC requests.
+ /// Optional developer-provided address override.
+ /// Optional allowed base addresses for SSRF protection.
+ /// Optional allowed URI schemes. Defaults to https only.
+ internal GrpcOperationRunner(
+ HttpClient httpClient,
+ Uri? addressOverride = null,
+ IReadOnlyList? allowedAddresses = null,
+ IReadOnlyList? allowedSchemes = null)
+ {
+ this._httpClient = httpClient;
+ this._addressOverride = addressOverride;
+ this._allowedAddresses = allowedAddresses;
+ this._allowedSchemes = allowedSchemes is { Count: > 0 } ? allowedSchemes : s_defaultAllowedSchemes;
+ }
///
/// Runs a gRPC operation.
@@ -47,7 +84,7 @@ public async Task 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 };
@@ -118,11 +155,16 @@ private static JsonObject ConvertResponse(object response, Type responseType)
/// Returns address of a channel that provides connection to a gRPC server.
///
/// The gRPC operation.
- /// The gRPC operation arguments.
/// The channel address.
- private string GetAddress(GrpcOperation operation, Dictionary 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;
}
@@ -132,6 +174,48 @@ private string GetAddress(GrpcOperation operation, Dictionary 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!;
}
diff --git a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs
index ee5f25c17c90..321312c5680c 100644
--- a/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs
+++ b/dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs
@@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc.Model;
///
internal sealed class GrpcOperation
{
- ///
- /// Name of 'address' argument used as override for the address provided by gRPC operation.
- ///
- internal const string AddressArgumentName = "address";
-
///
/// Name of 'payload' argument that represents gRPC operation request message.
///
@@ -90,12 +85,6 @@ public string FullServiceName
/// The list of parameters.
internal static List 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)
{
diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs
index 1e564e339a6e..001ea6bbc6d6 100644
--- a/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs
@@ -24,7 +24,7 @@ public GrpcOperationExtensionsTests()
}
[Fact]
- public void ThereShouldBeAddressParameter()
+ public void ThereShouldNotBeAddressParameter()
{
// Act
var parameters = GrpcOperation.CreateParameters();
@@ -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]
diff --git a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs
index 756ab5ce22fe..8ecdfe10ee0a 100644
--- a/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs
+++ b/dotnet/src/Functions/Functions.UnitTests/Grpc/GrpcRunnerTests.cs
@@ -73,7 +73,7 @@ public async Task ShouldUseAddressProvidedInGrpcOperationAsync()
}
[Fact]
- public async Task ShouldUseAddressOverrideFromArgumentsAsync()
+ public async Task ShouldIgnoreAddressFromArgumentsAsync()
{
// Arrange
this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
@@ -96,15 +96,278 @@ public async Task ShouldUseAddressOverrideFromArgumentsAsync()
var arguments = new KernelArguments
{
{ "payload", JsonSerializer.Serialize(new { name = "author" }) },
- { "address", "https://fake-random-test-host-from-args" }
+ { "address", "https://evil-host-from-llm" }
};
// Act
var result = await sut.RunAsync(operation, arguments);
- // Assert
+ // Assert - LLM-supplied address should be ignored, operation address should be used
Assert.NotNull(this._httpMessageHandlerStub.RequestUri);
- Assert.Equal("https://fake-random-test-host-from-args/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri);
+ Assert.Equal("https://fake-random-test-host/greet.Greeter/SayHello", this._httpMessageHandlerStub.RequestUri.AbsoluteUri);
+ }
+
+ [Fact]
+ public async Task ShouldUseAddressOverrideFromParametersAsync()
+ {
+ // Arrange
+ this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
+ this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]);
+ this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc");
+ this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0");
+
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var addressOverride = new Uri("https://developer-override-host");
+ var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://fake-random-test-host"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act
+ var result = await sut.RunAsync(operation, arguments);
+
+ // Assert - developer-provided override takes precedence
+ Assert.NotNull(this._httpMessageHandlerStub.RequestUri);
+ Assert.StartsWith("https://developer-override-host/", this._httpMessageHandlerStub.RequestUri.AbsoluteUri);
+ }
+
+ [Fact]
+ public async Task ShouldRejectAddressNotInAllowlistAsync()
+ {
+ // Arrange
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedAddresses = new[] { new Uri("https://allowed-host.com/") };
+ var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://not-allowed-host.com"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
+ Assert.Contains("not allowed", ex.Message);
+ }
+
+ [Fact]
+ public async Task ShouldAllowAddressInAllowlistAsync()
+ {
+ // Arrange
+ this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
+ this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]);
+ this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc");
+ this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0");
+
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedAddresses = new[] { new Uri("https://fake-random-test-host/") };
+ var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://fake-random-test-host"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act
+ var result = await sut.RunAsync(operation, arguments);
+
+ // Assert - should succeed
+ Assert.NotNull(result);
+ }
+
+ [Fact]
+ public async Task ShouldAllowAddressWithSubPathWhenAllowlistHasTrailingSlashAsync()
+ {
+ // Arrange
+ this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
+ this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]);
+ this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc");
+ this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0");
+
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedAddresses = new[] { new Uri("https://fake-random-test-host/grpc/") };
+ var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://fake-random-test-host/grpc/v1"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act
+ var result = await sut.RunAsync(operation, arguments);
+
+ // Assert - trailing slash in allowlist should allow sub-paths
+ Assert.NotNull(result);
+ }
+
+ [Fact]
+ public async Task ShouldAllowHttpWhenCustomSchemesPermitItAsync()
+ {
+ // Arrange
+ this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
+ this._httpMessageHandlerStub.ResponseToReturn.Content = new ByteArrayContent([0, 0, 0, 0, 14, 10, 12, 72, 101, 108, 108, 111, 32, 97, 117, 116, 104, 111, 114]);
+ this._httpMessageHandlerStub.ResponseToReturn.Content.Headers.Add("Content-Type", "application/grpc");
+ this._httpMessageHandlerStub.ResponseToReturn.TrailingHeaders.Add("grpc-status", "0");
+
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedSchemes = new[] { "https", "http" };
+ var sut = new GrpcOperationRunner(this._httpClient, allowedSchemes: allowedSchemes);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "http://localhost"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act
+ var result = await sut.RunAsync(operation, arguments);
+
+ // Assert - http should be allowed when custom schemes permit it
+ Assert.NotNull(result);
+ }
+
+ [Fact]
+ public async Task ShouldRejectNonHttpsSchemeByDefaultAsync()
+ {
+ // Arrange
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var sut = new GrpcOperationRunner(this._httpClient);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "http://insecure-host.com"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act & Assert
+ var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
+ Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ShouldRejectAddressThatSharesPrefixButIsNotAtPathBoundaryAsync()
+ {
+ // Arrange - allowlist without trailing slash, address extends the path segment
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedAddresses = new[] { new Uri("https://api.example.com/grpc") };
+ var sut = new GrpcOperationRunner(this._httpClient, allowedAddresses: allowedAddresses);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://api.example.com/grpcevil"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act & Assert - "grpcevil" should NOT match "grpc" since it's not at a path boundary
+ var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
+ Assert.Contains("not allowed", ex.Message);
+ }
+
+ [Fact]
+ public async Task ShouldRejectAddressOverrideNotInAllowlistAsync()
+ {
+ // Arrange
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var allowedAddresses = new[] { new Uri("https://allowed-host.com/") };
+ var addressOverride = new Uri("https://evil-host.com/");
+ var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride, allowedAddresses: allowedAddresses);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://allowed-host.com"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act & Assert - AddressOverride should also be validated against allowlist
+ var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
+ Assert.Contains("not allowed", ex.Message);
+ }
+
+ [Fact]
+ public async Task ShouldRejectAddressOverrideWithDisallowedSchemeAsync()
+ {
+ // Arrange
+ var requestMetadata = new GrpcOperationDataContractType("greet.HelloRequest", [new("name", 1, "TYPE_STRING")]);
+ var responseMetadata = new GrpcOperationDataContractType("greet.HelloReply", [new("message", 1, "TYPE_STRING")]);
+
+ var addressOverride = new Uri("http://insecure-host.com/");
+ var sut = new GrpcOperationRunner(this._httpClient, addressOverride: addressOverride);
+
+ var operation = new GrpcOperation("Greeter", "SayHello", requestMetadata, responseMetadata)
+ {
+ Package = "greet",
+ Address = "https://safe-host.com"
+ };
+
+ var arguments = new KernelArguments
+ {
+ { "payload", JsonSerializer.Serialize(new { name = "author" }) }
+ };
+
+ // Act & Assert - AddressOverride with http should be rejected when only https is allowed
+ var ex = await Assert.ThrowsAsync(() => sut.RunAsync(operation, arguments));
+ Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]