Skip to content

Commit f40cce1

Browse files
fix: address PR #1073 second-round feedback (security hardening iteration 4)
- Thread endUserId through GetChatCompletionCore, ProcessStreamingUpdatesAsync, and ExecuteFunctionCallAsync; include in tool-call and tool-rejected log messages for security telemetry - Emit responseId early from StreamingResponseCreatedUpdate so ChatController can record ownership before stream completes (handles mid-stream client disconnects) - Record responseId in streaming loop on first observation instead of post-loop, eliminating the disconnect race - Add SizeLimit=10_000 to AddMemoryCache to bound memory growth; add SetSize(1) per entry in ResponseIdValidationService - Fix MemoryCache disposal in ResponseIdValidationServiceTests: each test owns its cache via 'using var cache' - Fix IServiceScope disposal in McpApiTokenServiceTests: collect scopes in _scopes list, dispose all via [After(Test)] teardown
1 parent 068ff8f commit f40cce1

6 files changed

Lines changed: 67 additions & 39 deletions

File tree

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
6565
{
6666
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, endUserId: endUserId, cancellationToken: cancellationToken);
6767
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
68-
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, cancellationToken);
68+
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, endUserId, cancellationToken);
6969
}
7070

7171
/// <summary>
@@ -111,7 +111,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
111111
options: responseOptions,
112112
cancellationToken: cancellationToken);
113113

114-
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
114+
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, endUserId: endUserId, cancellationToken: cancellationToken))
115115
{
116116
yield return result;
117117
}
@@ -185,6 +185,7 @@ private static string SanitizeForXmlContext(string? input) =>
185185
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
186186
McpClient? mcpClient,
187187
int toolCallDepth = 0,
188+
string? endUserId = null,
188189
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
189190
{
190191
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
@@ -193,8 +194,10 @@ private static string SanitizeForXmlContext(string? input) =>
193194
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
194195
if (update is StreamingResponseCreatedUpdate created)
195196
{
196-
// Remember the response ID for later function calls
197+
// Emit the response ID early so the controller can record ownership
198+
// before the stream completes — handles client disconnects mid-stream.
197199
responseId = created.Response.Id;
200+
yield return (string.Empty, responseId: responseId);
198201
}
199202
else if (update is StreamingResponseOutputItemDoneUpdate itemDone)
200203
{
@@ -205,7 +208,7 @@ private static string SanitizeForXmlContext(string? input) =>
205208
throw new InvalidOperationException("Maximum tool call depth exceeded.");
206209

207210
// Execute the function call and stream its response
208-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
211+
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, endUserId, cancellationToken))
209212
{
210213
if (functionResult.responseId != null)
211214
{
@@ -237,12 +240,13 @@ private static string SanitizeForXmlContext(string? input) =>
237240
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
238241
McpClient mcpClient,
239242
int toolCallDepth,
243+
string? endUserId = null,
240244
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
241245
{
242246
// Defense-in-depth: validate tool name against static allowlist before executing.
243247
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
244248
{
245-
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName);
249+
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName, endUserId);
246250
// Feed a benign error back to the model so it can recover gracefully,
247251
// mirroring what GetChatCompletionCore does on the non-streaming path.
248252
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -257,14 +261,14 @@ private static string SanitizeForXmlContext(string? input) =>
257261
var recoveryStream = _ResponseClient.CreateResponseStreamingAsync(
258262
errorItems, responseOptions, cancellationToken);
259263
await foreach (var result in ProcessStreamingUpdatesAsync(
260-
recoveryStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
264+
recoveryStream, responseOptions, mcpClient, toolCallDepth, endUserId, cancellationToken))
261265
{
262266
yield return result;
263267
}
264268
yield break;
265269
}
266270

267-
LogMcpToolCallInvokedStream(_Logger, functionCallItem.FunctionName, toolCallDepth);
271+
LogMcpToolCallInvokedStream(_Logger, functionCallItem.FunctionName, toolCallDepth, endUserId);
268272
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
269273
Dictionary<string, object?> arguments = [];
270274
// example JsonResponse:
@@ -315,7 +319,7 @@ private static string SanitizeForXmlContext(string? input) =>
315319
responseOptions,
316320
cancellationToken);
317321

318-
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
322+
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, endUserId, cancellationToken))
319323
{
320324
yield return result;
321325
}
@@ -402,6 +406,7 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
402406
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
403407
string? systemPrompt = null,
404408
McpClient? mcpClient = null,
409+
string? endUserId = null,
405410
CancellationToken cancellationToken = default)
406411
{
407412
// Construct the user input with system context if provided
@@ -434,7 +439,7 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
434439
// This catches cases where the model hallucinates a tool name not on the list.
435440
if (!IsMcpToolAllowed(functionCallItem.FunctionName))
436441
{
437-
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName);
442+
LogMcpToolCallRejected(_Logger, functionCallItem.FunctionName, endUserId);
438443
// Return a benign error to the model so it can respond gracefully
439444
responseItems.Add(functionCallItem);
440445
responseItems.Add(new FunctionCallOutputResponseItem(
@@ -443,7 +448,7 @@ private async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
443448
continue;
444449
}
445450

446-
LogMcpToolCallInvoked(_Logger, functionCallItem.FunctionName, iteration);
451+
LogMcpToolCallInvoked(_Logger, functionCallItem.FunctionName, iteration, endUserId);
447452
var jsonResponse = functionCallItem.FunctionArguments.ToString();
448453
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
449454

@@ -500,14 +505,14 @@ private bool IsMcpToolAllowed(string toolName)
500505
return _AllowedMcpTools.Contains(toolName);
501506
}
502507

503-
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration}")]
504-
private static partial void LogMcpToolCallInvoked(ILogger logger, string toolName, int iteration);
508+
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked: tool={ToolName} iteration={Iteration} user={EndUserId}")]
509+
private static partial void LogMcpToolCallInvoked(ILogger logger, string toolName, int iteration, string? endUserId);
505510

506-
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked (streaming): tool={ToolName} depth={Depth}")]
507-
private static partial void LogMcpToolCallInvokedStream(ILogger logger, string toolName, int depth);
511+
[LoggerMessage(Level = LogLevel.Information, Message = "AI tool call invoked (streaming): tool={ToolName} depth={Depth} user={EndUserId}")]
512+
private static partial void LogMcpToolCallInvokedStream(ILogger logger, string toolName, int depth, string? endUserId);
508513

509-
[LoggerMessage(Level = LogLevel.Warning, Message = "AI tool call rejected — not on allowlist: tool={ToolName}")]
510-
private static partial void LogMcpToolCallRejected(ILogger logger, string toolName);
514+
[LoggerMessage(Level = LogLevel.Warning, Message = "AI tool call rejected — not on allowlist: tool={ToolName} user={EndUserId}")]
515+
private static partial void LogMcpToolCallRejected(ILogger logger, string toolName, string? endUserId);
511516

512517
[LoggerMessage(Level = LogLevel.Warning, Message = "MCP tool skipped during option setup — not on allowlist: tool={ToolName}")]
513518
private static partial void LogMcpToolSkippedNotAllowed(ILogger logger, string toolName);

EssentialCSharp.Web.Tests/McpApiTokenServiceTests.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,30 @@ namespace EssentialCSharp.Web.Tests;
99
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
1010
public class McpApiTokenServiceTests(WebApplicationFactory factory)
1111
{
12+
private readonly List<IServiceScope> _scopes = [];
13+
14+
[After(Test)]
15+
public void DisposeScopes()
16+
{
17+
foreach (var scope in _scopes)
18+
scope.Dispose();
19+
_scopes.Clear();
20+
}
21+
1222
private async Task<(string UserId, McpApiTokenService TokenService)> ArrangeAsync(string prefix)
1323
{
1424
string userId = await McpTestHelper.CreateUserAsync(factory, prefix);
15-
var tokenService = factory.Services.CreateScope().ServiceProvider
16-
.GetRequiredService<McpApiTokenService>();
25+
var scope = factory.Services.CreateScope();
26+
_scopes.Add(scope);
27+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
1728
return (userId, tokenService);
1829
}
1930

2031
private async Task<McpApiTokenService> FillToLimitAsync(string userId)
2132
{
22-
var tokenService = factory.Services.CreateScope().ServiceProvider
23-
.GetRequiredService<McpApiTokenService>();
33+
var scope = factory.Services.CreateScope();
34+
_scopes.Add(scope);
35+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
2436
for (int i = 0; i < McpApiTokenService.MaxTokensPerUser; i++)
2537
{
2638
await tokenService.CreateTokenAsync(userId, $"token-{i}");

EssentialCSharp.Web.Tests/ResponseIdValidationServiceTests.cs

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@ namespace EssentialCSharp.Web.Tests;
55

66
public class ResponseIdValidationServiceTests
77
{
8-
private static ResponseIdValidationService CreateService()
9-
=> new(new MemoryCache(new MemoryCacheOptions()));
8+
private static ResponseIdValidationService CreateService(MemoryCache cache) => new(cache);
109

1110
[Test]
1211
[Arguments(null)]
1312
[Arguments("")]
1413
public async Task ValidateResponseId_BlankResponseId_AllowsNewConversation(string? responseId)
1514
{
16-
var service = CreateService();
15+
using var cache = new MemoryCache(new MemoryCacheOptions());
16+
var service = CreateService(cache);
1717

1818
bool result = service.ValidateResponseId("user1", responseId);
1919

@@ -25,7 +25,8 @@ public async Task ValidateResponseId_BlankResponseId_AllowsNewConversation(strin
2525
[Arguments("")]
2626
public async Task ValidateResponseId_BlankUserId_Rejects(string? userId)
2727
{
28-
var service = CreateService();
28+
using var cache = new MemoryCache(new MemoryCacheOptions());
29+
var service = CreateService(cache);
2930

3031
bool result = service.ValidateResponseId(userId, "resp_123");
3132

@@ -35,7 +36,8 @@ public async Task ValidateResponseId_BlankUserId_Rejects(string? userId)
3536
[Test]
3637
public async Task ValidateResponseId_CacheMiss_AllowsGracefulDegradation()
3738
{
38-
var service = CreateService();
39+
using var cache = new MemoryCache(new MemoryCacheOptions());
40+
var service = CreateService(cache);
3941
// No RecordResponseId call — simulate server restart / different instance
4042

4143
bool result = service.ValidateResponseId("user1", "resp_unknown");
@@ -46,7 +48,8 @@ public async Task ValidateResponseId_CacheMiss_AllowsGracefulDegradation()
4648
[Test]
4749
public async Task ValidateResponseId_RecordedByOwner_Validates()
4850
{
49-
var service = CreateService();
51+
using var cache = new MemoryCache(new MemoryCacheOptions());
52+
var service = CreateService(cache);
5053
service.RecordResponseId("user1", "resp_abc");
5154

5255
bool result = service.ValidateResponseId("user1", "resp_abc");
@@ -57,7 +60,8 @@ public async Task ValidateResponseId_RecordedByOwner_Validates()
5760
[Test]
5861
public async Task ValidateResponseId_RecordedByDifferentUser_Rejects()
5962
{
60-
var service = CreateService();
63+
using var cache = new MemoryCache(new MemoryCacheOptions());
64+
var service = CreateService(cache);
6165
service.RecordResponseId("user1", "resp_abc");
6266

6367
bool result = service.ValidateResponseId("user2", "resp_abc");
@@ -68,7 +72,8 @@ public async Task ValidateResponseId_RecordedByDifferentUser_Rejects()
6872
[Test]
6973
public async Task RecordResponseId_NullInputs_DoesNotThrow()
7074
{
71-
var service = CreateService();
75+
using var cache = new MemoryCache(new MemoryCacheOptions());
76+
var service = CreateService(cache);
7277

7378
service.RecordResponseId(null, "resp_abc");
7479
service.RecordResponseId("user1", null);
@@ -82,7 +87,8 @@ public async Task RecordResponseId_NullInputs_DoesNotThrow()
8287
[Test]
8388
public async Task ValidateResponseId_MultipleResponseIds_EachValidatedIndependently()
8489
{
85-
var service = CreateService();
90+
using var cache = new MemoryCache(new MemoryCacheOptions());
91+
var service = CreateService(cache);
8692
service.RecordResponseId("user1", "resp_001");
8793
service.RecordResponseId("user1", "resp_002");
8894

@@ -95,7 +101,8 @@ public async Task ValidateResponseId_MultipleResponseIds_EachValidatedIndependen
95101
[Test]
96102
public async Task ValidateResponseId_TwoUsers_IsolatedFromEachOther()
97103
{
98-
var service = CreateService();
104+
using var cache = new MemoryCache(new MemoryCacheOptions());
105+
var service = CreateService(cache);
99106
service.RecordResponseId("user1", "resp_A");
100107
service.RecordResponseId("user2", "resp_B");
101108

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
8888

8989
try
9090
{
91-
string? finalResponseId = null;
91+
bool responseIdRecorded = false;
9292

9393
await foreach (var (text, responseId) in _AiChatService.GetChatCompletionStream(
9494
prompt: request.Message,
@@ -106,18 +106,19 @@ public async Task StreamMessage([FromBody] ChatMessageRequest request, Cancellat
106106

107107
if (!string.IsNullOrEmpty(responseId))
108108
{
109-
finalResponseId = responseId;
109+
// Record ownership as soon as the response ID is first observed —
110+
// if the client disconnects mid-stream the ownership is still cached.
111+
if (!responseIdRecorded)
112+
{
113+
_ResponseIdValidationService.RecordResponseId(userId, responseId);
114+
responseIdRecorded = true;
115+
}
110116
var eventData = JsonSerializer.Serialize(new { type = "responseId", data = responseId });
111117
await Response.WriteAsync($"data: {eventData}\n\n", cancellationToken);
112118
await Response.Body.FlushAsync(cancellationToken);
113119
}
114120
}
115121

116-
if (finalResponseId is not null)
117-
{
118-
_ResponseIdValidationService.RecordResponseId(userId, finalResponseId);
119-
}
120-
121122
await Response.WriteAsync("data: [DONE]\n\n", cancellationToken);
122123
await Response.Body.FlushAsync(cancellationToken);
123124
}

EssentialCSharp.Web/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ private static void Main(string[] args)
259259
// MCP server — always enabled, authenticated via opaque DB-backed tokens.
260260
builder.Services.AddScoped<McpApiTokenService>();
261261

262-
builder.Services.AddMemoryCache();
262+
builder.Services.AddMemoryCache(options => options.SizeLimit = 10_000);
263263
builder.Services.AddSingleton<ResponseIdValidationService>();
264264

265265
builder.Services.AddAuthentication()

EssentialCSharp.Web/Services/ResponseIdValidationService.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ public void RecordResponseId(string? userId, string? responseId)
3333
// Create a fresh options instance per call — MemoryCacheEntryOptions has mutable
3434
// list properties (ExpirationTokens, PostEvictionCallbacks) and sharing a static
3535
// instance would cause future additions to affect all entries.
36-
var entryOptions = new MemoryCacheEntryOptions().SetSlidingExpiration(TimeSpan.FromHours(2));
36+
// Size = 1 unit per entry is required when AddMemoryCache sets a SizeLimit.
37+
var entryOptions = new MemoryCacheEntryOptions()
38+
.SetSlidingExpiration(TimeSpan.FromHours(2))
39+
.SetSize(1);
3740
cache.Set(ResponseKey(responseId), userId, entryOptions);
3841
}
3942

0 commit comments

Comments
 (0)