From 24be5bb2f8c2b3b89d9d9d9ccb1d861352c647c7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 12:45:14 +0900 Subject: [PATCH 1/6] Limit LSP request id echo size (#3113) --- changelog.d/unreleased/3113.security.md | 16 +++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 15 +++++++++++- tests/CodeIndex.Tests/LspServerTests.cs | 32 +++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3113.security.md diff --git a/changelog.d/unreleased/3113.security.md b/changelog.d/unreleased/3113.security.md new file mode 100644 index 0000000000..eb5603e676 --- /dev/null +++ b/changelog.d/unreleased/3113.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3113 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP request ids now reject oversized raw JSON before echoing (#3113)** — JSON-RPC ids above the LSP raw id limit are rejected without parsing them into retained response state or reflecting the oversized value. + +## 日本語 + +- **LSP request id は oversized raw JSON を echo 前に拒否するようになりました (#3113)** — LSP の raw id 上限を超える JSON-RPC id は、レスポンス保持用の状態へ parse したり oversized value を反映したりせず拒否されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 401d62623e..70027fcf77 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -15,6 +15,7 @@ internal sealed class LspServer : IDisposable internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; + internal const int MaxLspRequestIdRawChars = 4 * 1024; internal const int MaxJsonDepth = 32; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -84,7 +85,8 @@ public int Run(Stream input, Stream output) var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; hasId = root.TryGetProperty("id", out var idElement); - id = hasId ? JsonNode.Parse(idElement.GetRawText(), documentOptions: LspJsonDocumentOptions) : null; + if (hasId && !TryParseRequestId(idElement, out id)) + return Error(null, -32600, $"Request id must be {MaxLspRequestIdRawChars} raw JSON characters or fewer."); if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; @@ -113,6 +115,17 @@ public int Run(Stream input, Stream output) } } + private static bool TryParseRequestId(JsonElement idElement, out JsonNode? id) + { + id = null; + var rawId = idElement.GetRawText(); + if (rawId.Length > MaxLspRequestIdRawChars) + return false; + + id = JsonNode.Parse(rawId, documentOptions: LspJsonDocumentOptions); + return true; + } + private JsonObject HandleShutdown(JsonNode? id) { _shutdownRequested = true; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 808fe62609..ef5bfabe90 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -174,6 +174,38 @@ public void HandleMessage_InternalFailure_ReturnsStableErrorMessage_Issue3200() } } + [Fact] + public void HandleMessage_OversizedRequestId_ReturnsInvalidRequestWithoutEcho_Issue3113() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_large_id"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var oversizedId = new string('A', LspServer.MaxLspRequestIdRawChars + 1); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = oversizedId, + method = "initialize", + @params = new { }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); + Assert.Contains("Request id must be", response["error"]!["message"]!.GetValue(), StringComparison.Ordinal); + Assert.Null(response["id"]); + Assert.DoesNotContain(oversizedId, response.ToJsonString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() { From 1529701afc01e7880d5437a7a8a5bb0fac60e5bd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 13:31:01 +0900 Subject: [PATCH 2/6] Preflight LSP request id raw byte length (#3113) --- changelog.d/unreleased/3113.security.md | 4 +- src/CodeIndex/Lsp/LspServer.cs | 62 +++++++++++++++++++++++-- tests/CodeIndex.Tests/LspServerTests.cs | 2 +- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/changelog.d/unreleased/3113.security.md b/changelog.d/unreleased/3113.security.md index eb5603e676..4d449ebae6 100644 --- a/changelog.d/unreleased/3113.security.md +++ b/changelog.d/unreleased/3113.security.md @@ -9,8 +9,8 @@ affected: ## English -- **LSP request ids now reject oversized raw JSON before echoing (#3113)** — JSON-RPC ids above the LSP raw id limit are rejected without parsing them into retained response state or reflecting the oversized value. +- **LSP request ids now reject oversized raw JSON before echoing (#3113)** — JSON-RPC ids above the LSP raw byte limit are rejected without parsing them into retained response state or reflecting the oversized value. ## 日本語 -- **LSP request id は oversized raw JSON を echo 前に拒否するようになりました (#3113)** — LSP の raw id 上限を超える JSON-RPC id は、レスポンス保持用の状態へ parse したり oversized value を反映したりせず拒否されます。 +- **LSP request id は oversized raw JSON を echo 前に拒否するようになりました (#3113)** — LSP の raw byte 上限を超える JSON-RPC id は、レスポンス保持用の状態へ parse したり oversized value を反映したりせず拒否されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 70027fcf77..6ab2fcdb68 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -15,12 +15,16 @@ internal sealed class LspServer : IDisposable internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; - internal const int MaxLspRequestIdRawChars = 4 * 1024; + internal const int MaxLspRequestIdRawBytes = 4 * 1024; internal const int MaxJsonDepth = 32; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; private const string JsonRpcInvalidParamsMessage = "Invalid params"; private const string JsonRpcInternalErrorMessage = "Internal error"; + private static readonly JsonReaderOptions LspJsonReaderOptions = new() + { + MaxDepth = MaxJsonDepth, + }; private static readonly JsonDocumentOptions LspJsonDocumentOptions = new() { MaxDepth = MaxJsonDepth, @@ -85,8 +89,8 @@ public int Run(Stream input, Stream output) var method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() : null; hasId = root.TryGetProperty("id", out var idElement); - if (hasId && !TryParseRequestId(idElement, out id)) - return Error(null, -32600, $"Request id must be {MaxLspRequestIdRawChars} raw JSON characters or fewer."); + if (hasId && !TryParseRequestId(payload, idElement, out id)) + return Error(null, -32600, $"Request id must be {MaxLspRequestIdRawBytes} raw JSON bytes or fewer."); if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; @@ -115,17 +119,65 @@ public int Run(Stream input, Stream output) } } - private static bool TryParseRequestId(JsonElement idElement, out JsonNode? id) + private static bool TryParseRequestId(string payload, JsonElement idElement, out JsonNode? id) { id = null; + if (!TryGetTopLevelRequestIdRawByteCount(payload, out var rawIdBytes) || rawIdBytes > MaxLspRequestIdRawBytes) + return false; + var rawId = idElement.GetRawText(); - if (rawId.Length > MaxLspRequestIdRawChars) + if (Encoding.UTF8.GetByteCount(rawId) > MaxLspRequestIdRawBytes) return false; id = JsonNode.Parse(rawId, documentOptions: LspJsonDocumentOptions); return true; } + private static bool TryGetTopLevelRequestIdRawByteCount(string payload, out int rawIdBytes) + { + rawIdBytes = 0; + var buffer = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(payload.Length)); + try + { + var byteCount = Encoding.UTF8.GetBytes(payload.AsSpan(), buffer); + var reader = new Utf8JsonReader(buffer.AsSpan(0, byteCount), LspJsonReaderOptions); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + return true; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == 0) + break; + if (reader.TokenType != JsonTokenType.PropertyName || reader.CurrentDepth != 1) + continue; + + var isId = reader.ValueTextEquals("id"u8); + if (!reader.Read()) + return false; + + var valueStart = reader.TokenStartIndex; + reader.Skip(); + if (isId) + { + var rawLength = reader.BytesConsumed - valueStart; + if (rawLength > int.MaxValue) + return false; + rawIdBytes = (int)rawLength; + } + } + + return true; + } + catch (JsonException) + { + return false; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + private JsonObject HandleShutdown(JsonNode? id) { _shutdownRequested = true; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index ef5bfabe90..9d5dc83484 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -183,7 +183,7 @@ public void HandleMessage_OversizedRequestId_ReturnsInvalidRequestWithoutEcho_Is var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); using var db = new DbContext(dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var oversizedId = new string('A', LspServer.MaxLspRequestIdRawChars + 1); + var oversizedId = new string('A', LspServer.MaxLspRequestIdRawBytes + 1); var request = JsonSerializer.Serialize(new { jsonrpc = "2.0", From 0afa062b3f2aece9b1938d8ce84368d4ce969831 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 13:37:16 +0900 Subject: [PATCH 3/6] Tighten LSP request id preflight allocation (#3113) --- src/CodeIndex/Lsp/LspServer.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 6ab2fcdb68..60d4f890b6 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -136,11 +136,12 @@ private static bool TryParseRequestId(string payload, JsonElement idElement, out private static bool TryGetTopLevelRequestIdRawByteCount(string payload, out int rawIdBytes) { rawIdBytes = 0; - var buffer = ArrayPool.Shared.Rent(Encoding.UTF8.GetMaxByteCount(payload.Length)); + var payloadByteCount = Encoding.UTF8.GetByteCount(payload); + var buffer = ArrayPool.Shared.Rent(payloadByteCount); try { - var byteCount = Encoding.UTF8.GetBytes(payload.AsSpan(), buffer); - var reader = new Utf8JsonReader(buffer.AsSpan(0, byteCount), LspJsonReaderOptions); + _ = Encoding.UTF8.GetBytes(payload.AsSpan(), buffer); + var reader = new Utf8JsonReader(buffer.AsSpan(0, payloadByteCount), LspJsonReaderOptions); if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) return true; From 042e3c53217e8c89a94355db0e222acb356edcba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 14:34:26 +0900 Subject: [PATCH 4/6] Strengthen LSP request id byte-bound test (#3113) --- tests/CodeIndex.Tests/LspServerTests.cs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 9d5dc83484..d1100e23de 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -183,14 +183,8 @@ public void HandleMessage_OversizedRequestId_ReturnsInvalidRequestWithoutEcho_Is var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); using var db = new DbContext(dbPath); using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); - var oversizedId = new string('A', LspServer.MaxLspRequestIdRawBytes + 1); - var request = JsonSerializer.Serialize(new - { - jsonrpc = "2.0", - id = oversizedId, - method = "initialize", - @params = new { }, - }); + var oversizedId = new string('\u00e9', LspServer.MaxLspRequestIdRawBytes / 2); + var request = $"{{\"jsonrpc\":\"2.0\",\"id\":\"{oversizedId}\",\"method\":\"initialize\",\"params\":{{}}}}"; var response = server.HandleMessage(request); From 1b0666677a7dc921c3e6c033e6b7e47e6f4c2d5b Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:10:03 +0900 Subject: [PATCH 5/6] Fix MCP audit request id truncation test (#3308) --- tests/CodeIndex.Tests/McpAuditLogTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/McpAuditLogTests.cs b/tests/CodeIndex.Tests/McpAuditLogTests.cs index bcfe7c9c51..610f9bb264 100644 --- a/tests/CodeIndex.Tests/McpAuditLogTests.cs +++ b/tests/CodeIndex.Tests/McpAuditLogTests.cs @@ -37,7 +37,7 @@ public void Dispose() private McpServer CreateServer(AuditLogSink sink) => new(_dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: false, sink); - private McpServer CreateServerWithFilter(AuditLogSink sink, McpToolFilter filter) => + private McpServer CreateServerWithFilter(AuditLogSil sink, McpToolFilter filter) => new(_dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: false, serializeResponse: null, authenticator: null, toolFilter: filter, auditLog: sink); [Fact] @@ -464,7 +464,7 @@ public void ToolsCall_TruncatesAuditRequestId_Issue3237() { using var sink = new AuditLogSink(_auditPath, AuditLogSink.DefaultMaxBytes, includeValues: false); using var server = CreateServer(sink); - var id = new string('r', AuditLogSink.MaxRequestIdChars + 25); + var id = new string('\u00e9', McpServer.MaxRequestIdCharacterCount); var serializedId = JsonSerializer.Serialize(id); var display = McpBoundedText.ForDisplay(serializedId, AuditLogSink.MaxRequestIdChars); var request = new JsonObject From 94a535371b926d0e354facc0bc33ed501e282231 Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:57:44 +0900 Subject: [PATCH 6/6] Repair MCP audit request id test blob (#3308) --- tests/CodeIndex.Tests/McpAuditLogTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/McpAuditLogTests.cs b/tests/CodeIndex.Tests/McpAuditLogTests.cs index 610f9bb264..663363d1e9 100644 --- a/tests/CodeIndex.Tests/McpAuditLogTests.cs +++ b/tests/CodeIndex.Tests/McpAuditLogTests.cs @@ -37,7 +37,7 @@ public void Dispose() private McpServer CreateServer(AuditLogSink sink) => new(_dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: false, sink); - private McpServer CreateServerWithFilter(AuditLogSil sink, McpToolFilter filter) => + private McpServer CreateServerWithFilter(AuditLogSink sink, McpToolFilter filter) => new(_dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: false, serializeResponse: null, authenticator: null, toolFilter: filter, auditLog: sink); [Fact]