diff --git a/changelog.d/unreleased/3113.security.md b/changelog.d/unreleased/3113.security.md new file mode 100644 index 0000000000..1d5fa6772a --- /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/LspServerRequestIdTests.cs +--- + +## English + +- **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 byte 上限を超える JSON-RPC id は、レスポンス保持用の状態へ parse したり oversized value を反映したりせず拒否されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index d706360d08..5677275341 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -17,12 +17,17 @@ internal sealed class LspServer : IDisposable internal const int MaxLspHeaderLineBytes = 8 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; + internal const int MaxLspRequestIdRawBytes = 4 * 1024; internal const int MaxJsonDepth = 32; internal const int MaxUnknownMethodDiagnosticChars = 240; 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, @@ -87,7 +92,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(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; @@ -116,6 +122,66 @@ public int Run(Stream input, Stream output) } } + 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 (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 payloadByteCount = Encoding.UTF8.GetByteCount(payload); + var buffer = ArrayPool.Shared.Rent(payloadByteCount); + try + { + _ = Encoding.UTF8.GetBytes(payload.AsSpan(), buffer); + var reader = new Utf8JsonReader(buffer.AsSpan(0, payloadByteCount), 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 static string SanitizeUnknownMethod(string method) { var wasTruncated = method.Length > MaxUnknownMethodDiagnosticChars; diff --git a/tests/CodeIndex.Tests/LspServerRequestIdTests.cs b/tests/CodeIndex.Tests/LspServerRequestIdTests.cs new file mode 100644 index 0000000000..be72febd48 --- /dev/null +++ b/tests/CodeIndex.Tests/LspServerRequestIdTests.cs @@ -0,0 +1,42 @@ +using CodeIndex.Cli; +using CodeIndex.Database; +using CodeIndex.Lsp; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public class LspServerRequestIdTests +{ + [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('\u00e9', LspServer.MaxLspRequestIdRawBytes / 2); + var request = $"{{\"jsonrpc\":\"2.0\",\"id\":\"{oversizedId}\",\"method\":\"initialize\",\"params\":{{}}}}"; + + 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); + } + } +}