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]