Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/EndpointNormalizerTests.cs
T
Oliver Kieselbach 2c7efa2733 backend: key-safe X-MCP-Tool-Name prefix for the usage row key (CodeQL cs/log-forging #91)
The caller-supplied X-MCP-Tool-Name header went verbatim into the UserUsageLog
row key ({yyyyMMdd}_{tool}:{endpoint}) and from there into the CAS-conflict
warning of TableCasRetry. EndpointNormalizer.ToolNameKey now strips CR/LF and
every other control character or line separator, replaces the characters Table
Storage rejects in keys (/ \ # ?) and caps the length at 64; an empty result
skips the prefix. Real tool names pass through unchanged, so existing usage
rows keep their keys. A header with an illegal key character previously made
every usage increment fail with 400 (fail-soft, counter never advanced).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 22:19:56 +02:00

58 lines
2.1 KiB
C#

using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
public class EndpointNormalizerTests
{
[Fact]
public void Normalize_StripsApiPrefixReplacesGuidsAndKeyChars()
{
var normalized = EndpointNormalizer.Normalize("/api/Sessions/0f8fad5b-d9cb-469f-a165-70867728950e/events?x=1");
Assert.Equal("sessions_{id}_events_x=1", normalized);
}
[Fact]
public void ToolNameKey_RealToolName_PassesThroughUnchanged()
{
// Existing UserUsageLog rows are keyed by "<tool>:<endpoint>"; a real tool name must keep its key.
Assert.Equal("get_session_events", EndpointNormalizer.ToolNameKey("get_session_events"));
}
[Fact]
public void ToolNameKey_NullOrEmpty_ReturnsEmpty()
{
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(null));
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey(string.Empty));
}
[Fact]
public void ToolNameKey_WithCrlfAndControlChars_StripsThem()
{
// The log-forging vector: a header that tries to inject a fake log line through the row key.
var forged = $"get_metrics\r\n[McpQuota] FAKE{(char)0}{(char)27}{(char)0x2028}x";
var key = EndpointNormalizer.ToolNameKey(forged);
Assert.Equal("get_metrics[McpQuota] FAKEx", key);
}
[Fact]
public void ToolNameKey_WithTableKeyIllegalChars_ReplacesThem()
{
// '/', '\', '#', '?' are rejected by Table Storage in keys — a header carrying them would make
// every usage increment fail with 400, so they are replaced the same way the path is.
Assert.Equal("a_b_c_d_e", EndpointNormalizer.ToolNameKey("a/b\\c#d?e"));
}
[Fact]
public void ToolNameKey_LongerThanCap_IsTruncated()
{
var key = EndpointNormalizer.ToolNameKey(new string('t', EndpointNormalizer.MaxToolNameLength + 10));
Assert.Equal(EndpointNormalizer.MaxToolNameLength, key.Length);
}
[Fact]
public void ToolNameKey_OnlyControlChars_ReturnsEmpty()
{
Assert.Equal(string.Empty, EndpointNormalizer.ToolNameKey("\r\n "));
}
}