Skip to content

Commit 52d4e5c

Browse files
.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 <223556219+Copilot@users.noreply.github.com>
1 parent 73d3c59 commit 52d4e5c

6 files changed

Lines changed: 429 additions & 37 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.Net.Http;
7+
8+
namespace Microsoft.SemanticKernel.Plugins.Grpc;
9+
10+
/// <summary>
11+
/// gRPC function execution parameters.
12+
/// </summary>
13+
[Experimental("SKEXP0040")]
14+
public class GrpcFunctionExecutionParameters
15+
{
16+
/// <summary>
17+
/// HttpClient to use for sending gRPC requests.
18+
/// </summary>
19+
public HttpClient? HttpClient { get; set; }
20+
21+
/// <summary>
22+
/// Developer-provided address override for the gRPC channel.
23+
/// When set, this address is used instead of the one from the .proto document.
24+
/// This value is controlled by the developer, not by the LLM.
25+
/// </summary>
26+
public Uri? AddressOverride { get; set; }
27+
28+
/// <summary>
29+
/// Gets or sets the allowed gRPC server base addresses.
30+
/// If set, only requests to addresses that start with one of these base URIs will be permitted.
31+
/// This helps prevent Server-Side Request Forgery (SSRF) attacks.
32+
/// If null, no base address restriction is applied (scheme validation still applies).
33+
/// </summary>
34+
public IReadOnlyList<Uri>? AllowedAddresses { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets the allowed URI schemes for gRPC server addresses.
38+
/// If null or empty, only <c>https</c> is permitted.
39+
/// </summary>
40+
public IReadOnlyList<string>? AllowedSchemes { get; set; }
41+
}

dotnet/src/Functions/Functions.Grpc/Extensions/GrpcKernelExtensions.cs

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ public static class GrpcKernelExtensions
2929
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
3030
/// <param name="parentDirectory">Directory containing the plugin directory.</param>
3131
/// <param name="pluginDirectoryName">Name of the directory containing the selected plugin.</param>
32+
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
3233
/// <returns>A list of all the prompt functions representing the plugin.</returns>
3334
public static KernelPlugin ImportPluginFromGrpcDirectory(
3435
this Kernel kernel,
3536
string parentDirectory,
36-
string pluginDirectoryName)
37+
string pluginDirectoryName,
38+
GrpcFunctionExecutionParameters? executionParameters = null)
3739
{
38-
KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName);
40+
KernelPlugin plugin = CreatePluginFromGrpcDirectory(kernel, parentDirectory, pluginDirectoryName, executionParameters);
3941
kernel.Plugins.Add(plugin);
4042
return plugin;
4143
}
@@ -46,13 +48,15 @@ public static KernelPlugin ImportPluginFromGrpcDirectory(
4648
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
4749
/// <param name="filePath">File path to .proto document.</param>
4850
/// <param name="pluginName">Name of the plugin to register.</param>
51+
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
4952
/// <returns>A list of all the prompt functions representing the plugin.</returns>
5053
public static KernelPlugin ImportPluginFromGrpcFile(
5154
this Kernel kernel,
5255
string filePath,
53-
string pluginName)
56+
string pluginName,
57+
GrpcFunctionExecutionParameters? executionParameters = null)
5458
{
55-
KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName);
59+
KernelPlugin plugin = CreatePluginFromGrpcFile(kernel, filePath, pluginName, executionParameters);
5660
kernel.Plugins.Add(plugin);
5761
return plugin;
5862
}
@@ -63,13 +67,15 @@ public static KernelPlugin ImportPluginFromGrpcFile(
6367
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
6468
/// <param name="documentStream">.proto document stream.</param>
6569
/// <param name="pluginName">Plugin name.</param>
70+
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
6671
/// <returns>A list of all the prompt functions representing the plugin.</returns>
6772
public static KernelPlugin ImportPluginFromGrpc(
6873
this Kernel kernel,
6974
Stream documentStream,
70-
string pluginName)
75+
string pluginName,
76+
GrpcFunctionExecutionParameters? executionParameters = null)
7177
{
72-
KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName);
78+
KernelPlugin plugin = CreatePluginFromGrpc(kernel, documentStream, pluginName, executionParameters);
7379
kernel.Plugins.Add(plugin);
7480
return plugin;
7581
}
@@ -80,11 +86,13 @@ public static KernelPlugin ImportPluginFromGrpc(
8086
/// <param name="kernel">The <see cref="Kernel"/> containing services, plugins, and other state for use throughout the operation.</param>
8187
/// <param name="parentDirectory">Directory containing the plugin directory.</param>
8288
/// <param name="pluginDirectoryName">Name of the directory containing the selected plugin.</param>
89+
/// <param name="executionParameters">Optional gRPC function execution parameters.</param>
8390
/// <returns>A list of all the prompt functions representing the plugin.</returns>
8491
public static KernelPlugin CreatePluginFromGrpcDirectory(
8592
this Kernel kernel,
8693
string parentDirectory,
87-
string pluginDirectoryName)
94+
string pluginDirectoryName,
95+
GrpcFunctionExecutionParameters? executionParameters = null)
8896
{
8997
const string ProtoFile = "grpc.proto";
9098

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

108116
using var stream = File.OpenRead(filePath);
109117

110-
return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName);
118+
return kernel.CreatePluginFromGrpc(stream, pluginDirectoryName, executionParameters);
111119
}
112120

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

136146
using var stream = File.OpenRead(filePath);
137147

138-
return kernel.CreatePluginFromGrpc(stream, pluginName);
148+
return kernel.CreatePluginFromGrpc(stream, pluginName, executionParameters);
139149
}
140150

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

163175
ILoggerFactory loggerFactory = kernel.LoggerFactory;
164176

165-
using var client = HttpClientProvider.GetHttpClient(kernel.Services.GetService<HttpClient>());
177+
var client = HttpClientProvider.GetHttpClient(executionParameters?.HttpClient ?? kernel.Services.GetService<HttpClient>());
166178

167-
var runner = new GrpcOperationRunner(client);
179+
var runner = new GrpcOperationRunner(
180+
client,
181+
executionParameters?.AddressOverride,
182+
executionParameters?.AllowedAddresses,
183+
executionParameters?.AllowedSchemes);
168184

169185
ILogger logger = loggerFactory.CreateLogger(typeof(GrpcKernelExtensions)) ?? NullLogger.Instance;
170186
foreach (var operation in operations)

dotnet/src/Functions/Functions.Grpc/GrpcOperationRunner.cs

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,53 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc;
2222
/// <summary>
2323
/// Runs gRPC operation runner.
2424
/// </summary>
25-
internal sealed class GrpcOperationRunner(HttpClient httpClient)
25+
internal sealed class GrpcOperationRunner
2626
{
2727
/// <summary>Serialization options that use a camel casing naming policy.</summary>
2828
private static readonly JsonSerializerOptions s_camelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
2929
/// <summary>Deserialization options that use case-insensitive property names.</summary>
3030
private static readonly JsonSerializerOptions s_propertyCaseInsensitiveOptions = new() { PropertyNameCaseInsensitive = true };
31+
32+
private static readonly IReadOnlyList<string> s_defaultAllowedSchemes = ["https"];
33+
3134
/// <summary>
3235
/// An instance of the HttpClient class.
3336
/// </summary>
34-
private readonly HttpClient _httpClient = httpClient;
37+
private readonly HttpClient _httpClient;
38+
39+
/// <summary>
40+
/// Developer-provided address override for the gRPC channel.
41+
/// </summary>
42+
private readonly Uri? _addressOverride;
43+
44+
/// <summary>
45+
/// Allowed gRPC server base addresses for SSRF protection.
46+
/// </summary>
47+
private readonly IReadOnlyList<Uri>? _allowedAddresses;
48+
49+
/// <summary>
50+
/// Allowed URI schemes for gRPC server addresses.
51+
/// </summary>
52+
private readonly IReadOnlyList<string> _allowedSchemes;
53+
54+
/// <summary>
55+
/// Creates an instance of a <see cref="GrpcOperationRunner"/> class.
56+
/// </summary>
57+
/// <param name="httpClient">The HttpClient to use for sending gRPC requests.</param>
58+
/// <param name="addressOverride">Optional developer-provided address override.</param>
59+
/// <param name="allowedAddresses">Optional allowed base addresses for SSRF protection.</param>
60+
/// <param name="allowedSchemes">Optional allowed URI schemes. Defaults to https only.</param>
61+
internal GrpcOperationRunner(
62+
HttpClient httpClient,
63+
Uri? addressOverride = null,
64+
IReadOnlyList<Uri>? allowedAddresses = null,
65+
IReadOnlyList<string>? allowedSchemes = null)
66+
{
67+
this._httpClient = httpClient;
68+
this._addressOverride = addressOverride;
69+
this._allowedAddresses = allowedAddresses;
70+
this._allowedSchemes = allowedSchemes is { Count: > 0 } ? allowedSchemes : s_defaultAllowedSchemes;
71+
}
3572

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

4885
var stringArgument = CastToStringArguments(arguments, operation);
4986

50-
var address = this.GetAddress(operation, stringArgument);
87+
var address = this.GetAddress(operation);
5188

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

@@ -118,11 +155,16 @@ private static JsonObject ConvertResponse(object response, Type responseType)
118155
/// Returns address of a channel that provides connection to a gRPC server.
119156
/// </summary>
120157
/// <param name="operation">The gRPC operation.</param>
121-
/// <param name="arguments">The gRPC operation arguments.</param>
122158
/// <returns>The channel address.</returns>
123-
private string GetAddress(GrpcOperation operation, Dictionary<string, string> arguments)
159+
private string GetAddress(GrpcOperation operation)
124160
{
125-
if (!arguments.TryGetValue(GrpcOperation.AddressArgumentName, out string? address))
161+
string? address;
162+
163+
if (this._addressOverride is not null)
164+
{
165+
address = this._addressOverride.AbsoluteUri;
166+
}
167+
else
126168
{
127169
address = operation.Address;
128170
}
@@ -132,6 +174,48 @@ private string GetAddress(GrpcOperation operation, Dictionary<string, string> ar
132174
throw new KernelException($"No address provided for the '{operation.Name}' gRPC operation.");
133175
}
134176

177+
if (!Uri.TryCreate(address, UriKind.Absolute, out var addressUri))
178+
{
179+
throw new KernelException($"The address '{address}' for the '{operation.Name}' gRPC operation is not a valid absolute URI.");
180+
}
181+
182+
// Validate scheme
183+
if (!this._allowedSchemes.Contains(addressUri.Scheme, StringComparer.OrdinalIgnoreCase))
184+
{
185+
throw new KernelException($"The URI scheme '{addressUri.Scheme}' is not allowed for the '{operation.Name}' gRPC operation. Allowed schemes: {string.Join(", ", this._allowedSchemes)}.");
186+
}
187+
188+
// Validate against allowed addresses
189+
if (this._allowedAddresses is { Count: > 0 })
190+
{
191+
bool isAllowed = false;
192+
foreach (var allowedAddress in this._allowedAddresses)
193+
{
194+
string allowedUri = allowedAddress.AbsoluteUri;
195+
196+
if (addressUri.AbsoluteUri.StartsWith(allowedUri, StringComparison.OrdinalIgnoreCase))
197+
{
198+
// If the allowed URI already ends at a boundary (e.g., trailing '/'),
199+
// or the full URIs match exactly, no further check is needed.
200+
// Otherwise, ensure the next character is a path boundary to prevent
201+
// prefix bypasses (e.g., allowed "https://host/grpc" should not match "https://host/grpcevil").
202+
int prefixLength = allowedUri.Length;
203+
if (prefixLength >= addressUri.AbsoluteUri.Length ||
204+
allowedUri[prefixLength - 1] is '/' ||
205+
addressUri.AbsoluteUri[prefixLength] is '/' or '?' or '#')
206+
{
207+
isAllowed = true;
208+
break;
209+
}
210+
}
211+
}
212+
213+
if (!isAllowed)
214+
{
215+
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.");
216+
}
217+
}
218+
135219
return address!;
136220
}
137221

dotnet/src/Functions/Functions.Grpc/Model/GrpcOperation.cs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,6 @@ namespace Microsoft.SemanticKernel.Plugins.Grpc.Model;
99
/// </summary>
1010
internal sealed class GrpcOperation
1111
{
12-
/// <summary>
13-
/// Name of 'address' argument used as override for the address provided by gRPC operation.
14-
/// </summary>
15-
internal const string AddressArgumentName = "address";
16-
1712
/// <summary>
1813
/// Name of 'payload' argument that represents gRPC operation request message.
1914
/// </summary>
@@ -90,12 +85,6 @@ public string FullServiceName
9085
/// <returns>The list of parameters.</returns>
9186
internal static List<KernelParameterMetadata> CreateParameters() =>
9287
[
93-
// Register the "address" parameter so that it's possible to override it if needed.
94-
new(GrpcOperation.AddressArgumentName)
95-
{
96-
Description = "Address for gRPC channel to use.",
97-
},
98-
9988
// Register the "payload" parameter to be used as gRPC operation request message.
10089
new(GrpcOperation.PayloadArgumentName)
10190
{

dotnet/src/Functions/Functions.UnitTests/Grpc/Extensions/GrpcOperationExtensionsTests.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public GrpcOperationExtensionsTests()
2424
}
2525

2626
[Fact]
27-
public void ThereShouldBeAddressParameter()
27+
public void ThereShouldNotBeAddressParameter()
2828
{
2929
// Act
3030
var parameters = GrpcOperation.CreateParameters();
@@ -34,8 +34,7 @@ public void ThereShouldBeAddressParameter()
3434
Assert.NotEmpty(parameters);
3535

3636
var addressParameter = parameters.SingleOrDefault(p => p.Name == "address");
37-
Assert.NotNull(addressParameter);
38-
Assert.Equal("Address for gRPC channel to use.", addressParameter.Description);
37+
Assert.Null(addressParameter);
3938
}
4039

4140
[Fact]

0 commit comments

Comments
 (0)