.Net: Harden gRPC plugin address handling (#13961)

## Description

Add developer-controlled address configuration and validation for the
gRPC plugin.

### Motivation and Context

The gRPC plugin now supports `GrpcFunctionExecutionParameters` for
configuring address handling, including an optional address override,
allowed base addresses, and scheme restrictions.

### Changes

- Introduced `GrpcFunctionExecutionParameters` class for
developer-controlled gRPC channel configuration
- Added address validation with scheme and allowlist checks in
`GrpcOperationRunner`
- Plumbed execution parameters through all public methods in
`GrpcKernelExtensions`
- Updated and added unit tests

### Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
SergeyMenshykh
2026-05-07 18:00:17 +00:00
committed by GitHub
co-authored by Copilot
parent 73d3c59902
commit 52d4e5ce85
6 changed files with 429 additions and 37 deletions
@@ -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; }
}
@@ -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;
}
@@ -46,13 +48,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="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;
}
@@ -63,13 +67,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="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;
}
@@ -80,11 +86,13 @@ 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 CreatePluginFromGrpcDirectory(
this Kernel kernel,
string parentDirectory,
string pluginDirectoryName)
string pluginDirectoryName,
GrpcFunctionExecutionParameters? executionParameters = null)
{
const string ProtoFile = "grpc.proto";
@@ -107,7 +115,7 @@ public static class GrpcKernelExtensions
using var stream = File.OpenRead(filePath);
return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName);
return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName, executionParameters);
}
/// <summary>
@@ -116,11 +124,13 @@ 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="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))
{
@@ -135,7 +145,7 @@ public static class GrpcKernelExtensions
using var stream = File.OpenRead(filePath);
return kernel.CreatePluginFromGrpc(stream, pluginName);
return kernel.CreatePluginFromGrpc(stream, pluginName, executionParameters);
}
/// <summary>
@@ -144,11 +154,13 @@ 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="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);
@@ -162,9 +174,13 @@ public static class GrpcKernelExtensions
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)
@@ -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.
@@ -47,7 +84,7 @@ internal sealed class GrpcOperationRunner(HttpClient httpClient)
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 @@ internal sealed class GrpcOperationRunner(HttpClient httpClient)
/// 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;
}
@@ -132,6 +174,48 @@ internal sealed class GrpcOperationRunner(HttpClient httpClient)
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!;
}
@@ -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>
@@ -90,12 +85,6 @@ internal sealed class GrpcOperation
/// <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)
{
@@ -24,7 +24,7 @@ public class GrpcOperationExtensionsTests
}
[Fact]
public void ThereShouldBeAddressParameter()
public void ThereShouldNotBeAddressParameter()
{
// Act
var parameters = GrpcOperation.CreateParameters();
@@ -34,8 +34,7 @@ public class GrpcOperationExtensionsTests
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]
@@ -73,7 +73,7 @@ public sealed class GrpcRunnerTests : IDisposable
}
[Fact]
public async Task ShouldUseAddressOverrideFromArgumentsAsync()
public async Task ShouldIgnoreAddressFromArgumentsAsync()
{
// Arrange
this._httpMessageHandlerStub.ResponseToReturn.Version = new Version(2, 0);
@@ -96,15 +96,278 @@ public sealed class GrpcRunnerTests : IDisposable
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<KernelException>(() => 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<KernelException>(() => 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<KernelException>(() => 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<KernelException>(() => 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<KernelException>(() => sut.RunAsync(operation, arguments));
Assert.Contains("scheme", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]