mirror of
https://github.com/microsoft/semantic-kernel.git
synced 2026-10-12 02:19:06 +00:00
.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:
co-authored by
Copilot
parent
73d3c59902
commit
52d4e5ce85
@@ -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)
|
||||
{
|
||||
|
||||
+2
-3
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user