From 0634118f0bd61a96d3e0aff51862c5b135d48706 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:35:52 +0900 Subject: [PATCH 01/10] Bound LSP header parsing budgets (#3230) --- USER_GUIDE.md | 6 +++++- changelog.d/unreleased/3230.security.md | 17 +++++++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 8 ++++++++ tests/CodeIndex.Tests/LspServerTests.cs | 24 ++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3230.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 04e381cb24..b16628fed0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1972,7 +1972,9 @@ server over stdio. It reuses the existing CodeIndex database and exposes launch an arbitrary LSP command but do not speak MCP. Incoming `textDocument.uri` values are rejected before URI parsing when they exceed 4096 characters, matching the MCP resource URI limit and keeping error -responses bounded. +responses bounded. LSP frame parsing also rejects more than 64 header lines, +more than 65536 aggregate header bytes, any one header line above 8192 bytes, +or a body above 8388608 bytes before reading the message body. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4239,6 +4241,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて `textDocument/definition`、`textDocument/references` を公開します。 受信した `textDocument.uri` は 4096 文字を超える場合、URI parse の前に拒否されます。 これは MCP resource URI の上限と揃えており、エラー応答が過大にならないようにします。 +LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を +超える header、8192 bytes を超える単一 header 行、8388608 bytes を超える body を拒否します。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3230.security.md b/changelog.d/unreleased/3230.security.md new file mode 100644 index 0000000000..2133433a9c --- /dev/null +++ b/changelog.d/unreleased/3230.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3230 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP frame headers now have count and aggregate byte limits (#3230)** — the LSP parser rejects excessive header lines or aggregate header bytes before reading the message body. + +## 日本語 + +- **LSP frame header に行数と合計 byte 数の上限を追加しました (#3230)** — LSP parser は message body を読む前に過大な header 行数または合計 header byte 数を拒否します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index d706360d08..333e0842b5 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -15,6 +15,8 @@ internal sealed class LspServer : IDisposable private const int DefaultLimit = 50; internal const int MaxLspFrameBytes = 8 * 1024 * 1024; internal const int MaxLspHeaderLineBytes = 8 * 1024; + internal const int MaxLspHeaderCount = 64; + internal const int MaxLspHeaderBytes = 64 * 1024; internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; internal const int MaxJsonDepth = 32; @@ -568,6 +570,8 @@ internal static bool TryReadMessage(Stream input, out string payload) { payload = string.Empty; var contentLength = -1; + var headerCount = 0; + var headerBytes = 0; while (true) { var line = ReadAsciiLine(input); @@ -575,6 +579,10 @@ internal static bool TryReadMessage(Stream input, out string payload) return false; if (line.Length == 0) break; + headerCount++; + headerBytes += line.Length; + if (headerCount > MaxLspHeaderCount || headerBytes > MaxLspHeaderBytes) + return false; var colon = line.IndexOf(':'); if (colon <= 0) continue; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 8706c17011..621015fcbc 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -50,6 +50,30 @@ public void TryReadMessage_RejectsHeaderLineOverMaxLength() Assert.Equal(string.Empty, actual); } + [Fact] + public void TryReadMessage_RejectsHeaderCountOverMax_Issue3230() + { + var headers = Enumerable.Range(0, LspServer.MaxLspHeaderCount) + .Select(i => $"X-{i}: value"); + var bytes = Encoding.UTF8.GetBytes(string.Join("\r\n", headers) + "\r\nContent-Length: 2\r\n\r\n{}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + + [Fact] + public void TryReadMessage_RejectsAggregateHeaderBytesOverMax_Issue3230() + { + var maxLineHeader = "X-" + new string('A', LspServer.MaxLspHeaderLineBytes - 2); + var headers = Enumerable.Repeat(maxLineHeader, (LspServer.MaxLspHeaderBytes / LspServer.MaxLspHeaderLineBytes) + 1); + var bytes = Encoding.UTF8.GetBytes(string.Join("\r\n", headers) + "\r\nContent-Length: 2\r\n\r\n{}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + [Fact] public void TryReadMessage_RejectsFrameOverMaxLength() { From 633a898e93ee52b41208104ab08aefac8c71cb14 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:37:59 +0900 Subject: [PATCH 02/10] Reject duplicate LSP content length headers (#3229) --- USER_GUIDE.md | 6 ++++-- changelog.d/unreleased/3229.security.md | 17 +++++++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 4 ++++ tests/CodeIndex.Tests/LspServerTests.cs | 12 ++++++++++++ 4 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3229.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b16628fed0..c16a440ec7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1974,7 +1974,8 @@ Incoming `textDocument.uri` values are rejected before URI parsing when they exceed 4096 characters, matching the MCP resource URI limit and keeping error responses bounded. LSP frame parsing also rejects more than 64 header lines, more than 65536 aggregate header bytes, any one header line above 8192 bytes, -or a body above 8388608 bytes before reading the message body. +duplicate `Content-Length` headers, or a body above 8388608 bytes before +reading the message body. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4242,7 +4243,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて 受信した `textDocument.uri` は 4096 文字を超える場合、URI parse の前に拒否されます。 これは MCP resource URI の上限と揃えており、エラー応答が過大にならないようにします。 LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を -超える header、8192 bytes を超える単一 header 行、8388608 bytes を超える body を拒否します。 +超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 +8388608 bytes を超える body を拒否します。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3229.security.md b/changelog.d/unreleased/3229.security.md new file mode 100644 index 0000000000..0a873db3e1 --- /dev/null +++ b/changelog.d/unreleased/3229.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3229 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP frame parsing rejects duplicate `Content-Length` headers (#3229)** — duplicate length headers are no longer accepted even when they repeat the same value. + +## 日本語 + +- **LSP frame parsing が重複した `Content-Length` header を拒否するようになりました (#3229)** — 同じ値の繰り返しであっても、length header の重複は受理されません。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 333e0842b5..a333448c0a 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -570,6 +570,7 @@ internal static bool TryReadMessage(Stream input, out string payload) { payload = string.Empty; var contentLength = -1; + var hasContentLength = false; var headerCount = 0; var headerBytes = 0; while (true) @@ -590,6 +591,8 @@ internal static bool TryReadMessage(Stream input, out string payload) var value = line[(colon + 1)..].Trim(); if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase)) { + if (hasContentLength) + return false; if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) || parsed < 0 || parsed > MaxLspFrameBytes) @@ -597,6 +600,7 @@ internal static bool TryReadMessage(Stream input, out string payload) return false; } + hasContentLength = true; contentLength = parsed; } } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 621015fcbc..514a618be4 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -84,6 +84,18 @@ public void TryReadMessage_RejectsFrameOverMaxLength() Assert.Equal(string.Empty, actual); } + [Theory] + [InlineData("2", "2")] + [InlineData("2", "3")] + public void TryReadMessage_RejectsDuplicateContentLength_Issue3229(string firstLength, string secondLength) + { + var bytes = Encoding.UTF8.GetBytes($"Content-Length: {firstLength}\r\nContent-Length: {secondLength}\r\n\r\n{{}}"); + using var stream = new MemoryStream(bytes); + + Assert.False(LspServer.TryReadMessage(stream, out var actual)); + Assert.Equal(string.Empty, actual); + } + [Fact] public void HandleMessage_Initialize_AdvertisesCoreCapabilities() { From 36cf246f7a55b54cc77e2230bce674247a3d78c8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:41:08 +0900 Subject: [PATCH 03/10] Validate LSP request ids before cloning (#3204) --- USER_GUIDE.md | 7 +++- changelog.d/unreleased/3204.security.md | 17 ++++++++ src/CodeIndex/Lsp/LspServer.cs | 30 +++++++++++++- tests/CodeIndex.Tests/LspServerTests.cs | 52 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3204.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c16a440ec7..e3d69db2d4 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1975,7 +1975,9 @@ exceed 4096 characters, matching the MCP resource URI limit and keeping error responses bounded. LSP frame parsing also rejects more than 64 header lines, more than 65536 aggregate header bytes, any one header line above 8192 bytes, duplicate `Content-Length` headers, or a body above 8388608 bytes before -reading the message body. +reading the message body. Request IDs must be bounded JSON-RPC scalar values: +strings are capped at 256 characters, integer IDs must fit in `Int64`, and +non-scalar IDs are rejected as invalid requests before response IDs are cloned. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4245,6 +4247,9 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を 超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 8388608 bytes を超える body を拒否します。 +request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 +integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に +invalid request として拒否します。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3204.security.md b/changelog.d/unreleased/3204.security.md new file mode 100644 index 0000000000..2835827284 --- /dev/null +++ b/changelog.d/unreleased/3204.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3204 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP request IDs are validated before cloning (#3204)** — request IDs are now limited to bounded JSON-RPC scalar values before the server copies them into responses. + +## 日本語 + +- **LSP request ID を複製前に検証するようになりました (#3204)** — request ID は response へコピーされる前に、bounded な JSON-RPC scalar value に制限されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index a333448c0a..2555073ab8 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -20,6 +20,7 @@ internal sealed class LspServer : IDisposable internal const int MaxPositionDocumentBytes = 4 * 1024 * 1024; internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; internal const int MaxJsonDepth = 32; + internal const int MaxRequestIdStringChars = 256; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -89,7 +90,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 && !TryCloneRequestId(idElement, out id)) + return Error(null, -32600, "Invalid Request"); if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; @@ -118,6 +120,32 @@ public int Run(Stream input, Stream output) } } + private static bool TryCloneRequestId(JsonElement idElement, out JsonNode? id) + { + id = null; + switch (idElement.ValueKind) + { + case JsonValueKind.String: + var value = idElement.GetString(); + if (value == null || value.Length > MaxRequestIdStringChars) + return false; + id = JsonValue.Create(value); + return true; + + case JsonValueKind.Number: + if (!idElement.TryGetInt64(out var number)) + return false; + id = JsonValue.Create(number); + return true; + + case JsonValueKind.Null: + return true; + + default: + return false; + } + } + private static string SanitizeUnknownMethod(string method) { var wasTruncated = method.Length > MaxUnknownMethodDiagnosticChars; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 514a618be4..21d4612b25 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -203,6 +203,58 @@ public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3 } } + [Fact] + public void HandleMessage_ObjectRequestId_ReturnsInvalidRequest_Issue3204() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_object_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 response = server.HandleMessage("""{"jsonrpc":"2.0","id":{"nested":1},"method":"initialize"}"""); + + Assert.NotNull(response); + Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); + Assert.Null(response["id"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_OversizedStringRequestId_ReturnsInvalidRequest_Issue3204() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_long_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('i', LspServer.MaxRequestIdStringChars + 1); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = oversizedId, + method = "initialize", + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Equal(-32600, response!["error"]!["code"]!.GetValue()); + Assert.Null(response["id"]); + Assert.DoesNotContain(oversizedId, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_InvalidParams_ReturnsStableErrorMessage_Issue3200() { From 809528c81a76a4b9552a55eae37faa691b4cdd27 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:44:02 +0900 Subject: [PATCH 04/10] Validate LSP text document URI type (#3203) --- USER_GUIDE.md | 22 +++++++++------- changelog.d/unreleased/3203.security.md | 17 ++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 13 +++++++-- tests/CodeIndex.Tests/LspServerTests.cs | 35 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/3203.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e3d69db2d4..ad03859ce1 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1970,14 +1970,15 @@ server over stdio. It reuses the existing CodeIndex database and exposes `initialize`, `workspace/symbol`, `textDocument/documentSymbol`, `textDocument/definition`, and `textDocument/references` for editors that can launch an arbitrary LSP command but do not speak MCP. -Incoming `textDocument.uri` values are rejected before URI parsing when they -exceed 4096 characters, matching the MCP resource URI limit and keeping error -responses bounded. LSP frame parsing also rejects more than 64 header lines, -more than 65536 aggregate header bytes, any one header line above 8192 bytes, -duplicate `Content-Length` headers, or a body above 8388608 bytes before -reading the message body. Request IDs must be bounded JSON-RPC scalar values: -strings are capped at 256 characters, integer IDs must fit in `Int64`, and -non-scalar IDs are rejected as invalid requests before response IDs are cloned. +Incoming `textDocument.uri` values must be strings and are rejected before URI +parsing when they exceed 4096 characters, matching the MCP resource URI limit +and keeping error responses bounded. LSP frame parsing also rejects more than +64 header lines, more than 65536 aggregate header bytes, any one header line +above 8192 bytes, duplicate `Content-Length` headers, or a body above 8388608 +bytes before reading the message body. Request IDs must be bounded JSON-RPC +scalar values: strings are capped at 256 characters, integer IDs must fit in +`Int64`, and non-scalar IDs are rejected as invalid requests before response +IDs are cloned. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4242,8 +4243,9 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて 任意の LSP command を起動できるが MCP には対応していない editor 向けに `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 `textDocument/definition`、`textDocument/references` を公開します。 -受信した `textDocument.uri` は 4096 文字を超える場合、URI parse の前に拒否されます。 -これは MCP resource URI の上限と揃えており、エラー応答が過大にならないようにします。 +受信した `textDocument.uri` は string である必要があり、4096 文字を超える場合は +URI parse の前に拒否されます。これは MCP resource URI の上限と揃えており、 +エラー応答が過大にならないようにします。 LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を 超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 8388608 bytes を超える body を拒否します。 diff --git a/changelog.d/unreleased/3203.security.md b/changelog.d/unreleased/3203.security.md new file mode 100644 index 0000000000..634c6fc436 --- /dev/null +++ b/changelog.d/unreleased/3203.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3203 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `textDocument.uri` now has explicit type validation (#3203)** — document handlers reject non-string URI values before URI parsing or path resolution while keeping the JSON-RPC error bounded. + +## 日本語 + +- **LSP `textDocument.uri` の型検証を明示しました (#3203)** — document handler は URI parse や path resolution の前に string 以外の URI 値を拒否し、JSON-RPC error を bounded に保ちます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 2555073ab8..b89c389676 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -527,13 +527,22 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative private static string GetDocumentPath(JsonElement root) { - var uri = GetString(root, "params", "textDocument", "uri"); + var uri = GetTextDocumentUri(root); + return UriToPath(uri); + } + + private static string GetTextDocumentUri(JsonElement root) + { + if (!TryGet(root, out var value, "params", "textDocument", "uri") || value.ValueKind != JsonValueKind.String) + throw new ArgumentException("textDocument.uri must be a string."); + + var uri = value.GetString(); if (string.IsNullOrWhiteSpace(uri)) throw new ArgumentException("textDocument.uri is required."); if (uri.Length > MaxTextDocumentUriChars) throw new ArgumentException( $"textDocument.uri is too long. Max length is {MaxTextDocumentUriChars} characters; actual length is {uri.Length}."); - return UriToPath(uri); + return uri; } private static string? GetString(JsonElement root, params string[] path) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 21d4612b25..086cc9a58d 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -532,6 +532,41 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 } } + [Fact] + public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue3203() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_uri_type"); + 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 request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3203, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = 123 }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + var message = error["message"]!.GetValue(); + Assert.Equal("Invalid params", message); + Assert.DoesNotContain("123", response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() { From 890fef49e92680e2fb8662be25438fc487507e34 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:46:18 +0900 Subject: [PATCH 05/10] Reject non-file LSP document URIs (#3206) --- USER_GUIDE.md | 24 ++++++++--------- changelog.d/unreleased/3206.security.md | 17 ++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 2 +- tests/CodeIndex.Tests/LspServerTests.cs | 36 +++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 changelog.d/unreleased/3206.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index ad03859ce1..111a90b0fd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1970,15 +1970,15 @@ server over stdio. It reuses the existing CodeIndex database and exposes `initialize`, `workspace/symbol`, `textDocument/documentSymbol`, `textDocument/definition`, and `textDocument/references` for editors that can launch an arbitrary LSP command but do not speak MCP. -Incoming `textDocument.uri` values must be strings and are rejected before URI -parsing when they exceed 4096 characters, matching the MCP resource URI limit -and keeping error responses bounded. LSP frame parsing also rejects more than -64 header lines, more than 65536 aggregate header bytes, any one header line -above 8192 bytes, duplicate `Content-Length` headers, or a body above 8388608 -bytes before reading the message body. Request IDs must be bounded JSON-RPC -scalar values: strings are capped at 256 characters, integer IDs must fit in -`Int64`, and non-scalar IDs are rejected as invalid requests before response -IDs are cloned. +Incoming `textDocument.uri` values must be strings, must be absolute `file:` +URIs, and are rejected before URI parsing when they exceed 4096 characters, +matching the MCP resource URI limit and keeping error responses bounded. LSP +frame parsing also rejects more than 64 header lines, more than 65536 aggregate +header bytes, any one header line above 8192 bytes, duplicate `Content-Length` +headers, or a body above 8388608 bytes before reading the message body. Request +IDs must be bounded JSON-RPC scalar values: strings are capped at 256 +characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as +invalid requests before response IDs are cloned. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4243,9 +4243,9 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて 任意の LSP command を起動できるが MCP には対応していない editor 向けに `initialize`、`workspace/symbol`、`textDocument/documentSymbol`、 `textDocument/definition`、`textDocument/references` を公開します。 -受信した `textDocument.uri` は string である必要があり、4096 文字を超える場合は -URI parse の前に拒否されます。これは MCP resource URI の上限と揃えており、 -エラー応答が過大にならないようにします。 +受信した `textDocument.uri` は string かつ absolute `file:` URI である必要があり、 +4096 文字を超える場合は URI parse の前に拒否されます。これは MCP resource URI の上限と +揃えており、エラー応答が過大にならないようにします。 LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を 超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 8388608 bytes を超える body を拒否します。 diff --git a/changelog.d/unreleased/3206.security.md b/changelog.d/unreleased/3206.security.md new file mode 100644 index 0000000000..3b2ed3cd34 --- /dev/null +++ b/changelog.d/unreleased/3206.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3206 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP document operations reject non-file URIs (#3206)** — document handlers no longer treat `untitled:` or other non-`file:` URI schemes as workspace-relative paths. + +## 日本語 + +- **LSP document 操作が non-file URI を拒否するようになりました (#3206)** — document handler は `untitled:` など `file:` 以外の URI scheme を workspace-relative path として扱わなくなりました。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index b89c389676..c08f854f16 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -581,7 +581,7 @@ internal static string PathToUri(string path, string? projectRoot = null) internal static string UriToPath(string uri) { if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed) || !parsed.IsFile) - return uri; + throw new ArgumentException("textDocument.uri must be an absolute file URI."); return parsed.LocalPath; } diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 086cc9a58d..9a36a0ad03 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -567,6 +567,42 @@ public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue32 } } + [Theory] + [InlineData("untitled:scratch.cs")] + [InlineData("https://example.invalid/app.cs")] + public void HandleMessage_DocumentSymbol_RejectsNonFileTextDocumentUri_Issue3206(string uri) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_uri_scheme"); + 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 request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3206, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Equal("Invalid params", error["message"]!.GetValue()); + Assert.DoesNotContain(uri, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_ReturnsLocationForTokenAtPosition() { From 632c41955c1a3ae6c5670e3e168a56c20f36cd9d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:48:28 +0900 Subject: [PATCH 06/10] Bound LSP workspace symbol queries (#3128) --- USER_GUIDE.md | 4 ++- changelog.d/unreleased/3128.security.md | 17 ++++++++++++ src/CodeIndex/Lsp/LspServer.cs | 3 +++ tests/CodeIndex.Tests/LspServerTests.cs | 35 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3128.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 111a90b0fd..0e2f35a235 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1978,7 +1978,8 @@ header bytes, any one header line above 8192 bytes, duplicate `Content-Length` headers, or a body above 8388608 bytes before reading the message body. Request IDs must be bounded JSON-RPC scalar values: strings are capped at 256 characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as -invalid requests before response IDs are cloned. +invalid requests before response IDs are cloned. `workspace/symbol` query +strings are capped at 1000 characters before symbol search runs. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4252,6 +4253,7 @@ LSP frame parsing は、message body を読む前に 64 行を超える header request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に invalid request として拒否します。 +`workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3128.security.md b/changelog.d/unreleased/3128.security.md new file mode 100644 index 0000000000..e55f21537f --- /dev/null +++ b/changelog.d/unreleased/3128.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3128 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `workspace/symbol` queries now enforce the shared query length limit (#3128)** — oversized symbol queries are rejected before database search work starts. + +## 日本語 + +- **LSP `workspace/symbol` query に共通の query 長上限を適用しました (#3128)** — 過大な symbol query は database search を開始する前に拒否されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index c08f854f16..8ed291fd54 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -196,6 +196,9 @@ private JsonObject HandleShutdown(JsonNode? id) private JsonArray WorkspaceSymbol(JsonElement root) { var query = GetString(root, "params", "query"); + if (query != null && query.Length > QueryLimits.MaxQueryLength) + throw new ArgumentException(QueryLimits.FormatQueryTooLongError()); + var symbols = _reader.SearchSymbols(query, DefaultLimit); var array = new JsonArray(); foreach (var symbol in symbols) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 9a36a0ad03..10901a09b6 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -324,6 +324,41 @@ public void HandleMessage_InternalFailure_ReturnsStableErrorMessage_Issue3200() } } + [Fact] + public void HandleMessage_WorkspaceSymbol_RejectsOversizedQuery_Issue3128() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_workspace_symbol_long_query"); + 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 oversizedQuery = new string('q', QueryLimits.MaxQueryLength + 1); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3128, + method = "workspace/symbol", + @params = new + { + query = oversizedQuery, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32602, error["code"]!.GetValue()); + Assert.Equal("Invalid params", error["message"]!.GetValue()); + Assert.DoesNotContain(oversizedQuery, response.ToJsonString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() { From 3606671e2ed48bdcf7b748f645ff23ec05720880 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:51:51 +0900 Subject: [PATCH 07/10] Bound LSP document symbol responses (#3130) --- USER_GUIDE.md | 5 +++ changelog.d/unreleased/3130.security.md | 17 ++++++++ src/CodeIndex/Lsp/LspServer.cs | 26 ++++++++++-- tests/CodeIndex.Tests/LspServerTests.cs | 56 +++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/3130.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0e2f35a235..5599e7580d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1980,6 +1980,9 @@ IDs must be bounded JSON-RPC scalar values: strings are capped at 256 characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as invalid requests before response IDs are cloned. `workspace/symbol` query strings are capped at 1000 characters before symbol search runs. +`textDocument/documentSymbol` returns at most 1000 indexed symbols, truncates +each `detail` string to 512 characters with `...`, and stops adding symbols +before the result array exceeds 524288 JSON bytes. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4254,6 +4257,8 @@ request ID は bounded な JSON-RPC scalar value に限定され、string は 25 integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に invalid request として拒否します。 `workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 +`textDocument/documentSymbol` は最大 1000 件の indexed symbol を返し、各 `detail` string を +`...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3130.security.md b/changelog.d/unreleased/3130.security.md new file mode 100644 index 0000000000..a7968f061d --- /dev/null +++ b/changelog.d/unreleased/3130.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3130 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP `documentSymbol` responses now bound detail and response size (#3130)** — symbol details are truncated with an explicit marker and response arrays stop before exceeding the JSON byte budget. + +## 日本語 + +- **LSP `documentSymbol` response の detail と response size に上限を追加しました (#3130)** — symbol detail は明示的な marker 付きで切り詰め、response array は JSON byte 予算を超える前に打ち切ります。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 8ed291fd54..e6401de8b9 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -21,6 +21,9 @@ internal sealed class LspServer : IDisposable internal const int MaxTextDocumentUriChars = McpBoundedText.MaxResourceUriChars; internal const int MaxJsonDepth = 32; internal const int MaxRequestIdStringChars = 256; + internal const int MaxDocumentSymbols = 1000; + internal const int MaxDocumentSymbolDetailChars = 512; + internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -213,10 +216,20 @@ private JsonArray DocumentSymbol(JsonElement root) if (indexedPath == null) return []; - var symbols = _reader.SearchSymbols((string?)null, 1000, pathPatterns: [indexedPath]); + var symbols = _reader.SearchSymbols((string?)null, MaxDocumentSymbols, pathPatterns: [indexedPath]); var array = new JsonArray(); + var responseBytes = 2; foreach (var symbol in symbols.OrderBy(s => s.StartLine).ThenBy(s => s.Name, StringComparer.Ordinal)) - array.Add(ToDocumentSymbol(symbol)); + { + var item = ToDocumentSymbol(symbol); + var itemBytes = Encoding.UTF8.GetByteCount(item.ToJsonString(_jsonOptions)); + var separatorBytes = array.Count == 0 ? 0 : 1; + if (responseBytes + separatorBytes + itemBytes > MaxDocumentSymbolResponseBytes) + break; + + responseBytes += separatorBytes + itemBytes; + array.Add(item); + } return array; } @@ -493,9 +506,16 @@ private bool TryGetProjectRelativePath(string resolvedPath, out string? relative ["kind"] = SymbolKind(symbol.Kind), ["range"] = ToRange(symbol.StartLine, 1, symbol.EndLine, 1), ["selectionRange"] = ToRange(symbol.Line, 1, symbol.Line, 1), - ["detail"] = symbol.Signature, + ["detail"] = TruncateDocumentSymbolDetail(symbol.Signature), }; + private static string? TruncateDocumentSymbolDetail(string? detail) + { + if (detail == null || detail.Length <= MaxDocumentSymbolDetailChars) + return detail; + return detail[..(MaxDocumentSymbolDetailChars - "...".Length)] + "..."; + } + private JsonObject ToLocation(string path, int startLine, int startColumn, int endLine, int endColumn) => new() { ["uri"] = PathToUri(path, _projectRoot), diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 10901a09b6..48487a293b 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.Json; using CodeIndex.Cli; @@ -567,6 +568,61 @@ public void HandleMessage_DocumentSymbol_RejectsOversizedTextDocumentUri_Issue31 } } + [Fact] + public void HandleMessage_DocumentSymbol_TruncatesDetailsAndCapsResponse_Issue3130() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_document_symbol_budget"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "large.cs"); + var parameters = string.Join(", ", Enumerable.Range(0, 90).Select(i => $"int argument{i:D2}")); + var source = new StringBuilder("class LargeSymbols\n{\n"); + for (var i = 0; i < LspServer.MaxDocumentSymbols; i++) + source.Append(" void Method").Append(i.ToString("D4", CultureInfo.InvariantCulture)).Append('(').Append(parameters).Append(") { }\n"); + source.Append("}\n"); + + File.WriteAllText(sourcePath, source.ToString()); + TestProjectHelper.InsertIndexedFile(dbPath, "large.cs", "csharp", source.ToString()); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3130, + method = "textDocument/documentSymbol", + @params = new + { + textDocument = new { uri = new Uri(sourcePath).AbsoluteUri }, + }, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var symbols = response!["result"]!.AsArray(); + Assert.NotEmpty(symbols); + Assert.True(symbols.Count < LspServer.MaxDocumentSymbols); + Assert.True(Encoding.UTF8.GetByteCount(symbols.ToJsonString()) <= LspServer.MaxDocumentSymbolResponseBytes); + Assert.Contains(symbols, symbol => + { + var detail = symbol?["detail"]?.GetValue(); + return detail is { Length: <= LspServer.MaxDocumentSymbolDetailChars } + && detail.EndsWith("...", StringComparison.Ordinal); + }); + Assert.All(symbols, symbol => + { + var detail = symbol?["detail"]?.GetValue(); + if (detail != null) + Assert.True(detail.Length <= LspServer.MaxDocumentSymbolDetailChars); + }); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_DocumentSymbol_RejectsNonStringTextDocumentUri_Issue3203() { From 7006dd45980caad0eca31ca2e93f68a159b353f9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:56:19 +0900 Subject: [PATCH 08/10] Bound LSP position line reads (#3136) --- USER_GUIDE.md | 4 ++ changelog.d/unreleased/3136.security.md | 17 +++++++++ src/CodeIndex/Lsp/LspServer.cs | 49 +++++++++++++++++++++---- tests/CodeIndex.Tests/LspServerTests.cs | 31 ++++++++++++++++ 4 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/3136.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5599e7580d..688f94e081 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1983,6 +1983,8 @@ strings are capped at 1000 characters before symbol search runs. `textDocument/documentSymbol` returns at most 1000 indexed symbols, truncates each `detail` string to 512 characters with `...`, and stops adding symbols before the result array exceeds 524288 JSON bytes. +Position-based `definition` and `references` lookups read at most 16384 +characters from the target source line before returning an empty result. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4259,6 +4261,8 @@ invalid request として拒否します。 `workspace/symbol` の query string は symbol search を実行する前に 1000 文字で上限をかけます。 `textDocument/documentSymbol` は最大 1000 件の indexed symbol を返し、各 `detail` string を `...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 +position-based な `definition` / `references` lookup は、対象 source line を最大 16384 文字まで読み、 +超過時は空の result を返します。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3136.security.md b/changelog.d/unreleased/3136.security.md new file mode 100644 index 0000000000..60a4f4eddf --- /dev/null +++ b/changelog.d/unreleased/3136.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3136 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP position lookups now cap target line length (#3136)** — definition and references requests stop reading oversized source lines before token extraction and return an empty result. + +## 日本語 + +- **LSP position lookup の対象行長に上限を追加しました (#3136)** — definition / references request は token extraction の前に過大な source line の読み取りを止め、空の result を返します。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index e6401de8b9..339361c988 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -24,6 +24,7 @@ internal sealed class LspServer : IDisposable internal const int MaxDocumentSymbols = 1000; internal const int MaxDocumentSymbolDetailChars = 512; internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; + internal const int MaxPositionLineChars = 16 * 1024; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -329,24 +330,56 @@ private static bool TryReadPositionLine(string path, int targetLine, out string return false; using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - for (var currentLine = 0; currentLine <= targetLine; currentLine++) + var currentLine = 0; + var currentLineLength = 0; + StringBuilder? builder = targetLine == 0 ? new StringBuilder() : null; + while (true) { - var line = reader.ReadLine(); - if (line == null) + var next = reader.Read(); + if (next < 0) + { + if (currentLine == targetLine && currentLineLength <= MaxPositionLineChars && builder != null) + { + sourceLine = builder.ToString(); + return true; + } + return false; - if (currentLine == targetLine) + } + + var c = (char)next; + if (c == '\r' || c == '\n') { - sourceLine = line; - return true; + if (c == '\r' && reader.Peek() == '\n') + reader.Read(); + + if (currentLine == targetLine) + { + sourceLine = builder?.ToString() ?? string.Empty; + return true; + } + + currentLine++; + currentLineLength = 0; + builder = currentLine == targetLine ? new StringBuilder() : null; + continue; } + + currentLineLength++; + if (currentLineLength > MaxPositionLineChars) + { + if (currentLine == targetLine) + return false; + continue; + } + + builder?.Append(c); } } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) { return false; } - - return false; } internal static string? ExtractTokenAtUtf16Position(string line, int character) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 48487a293b..5a2c2f87d3 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -965,6 +965,37 @@ public void HandleMessage_Definition_ReturnsEmptyForOversizedIndexedDocument() } } + [Fact] + public void HandleMessage_Definition_ReturnsEmptyForLineOverPositionBudget_Issue3136() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_long_line"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "long_line.cs"); + var indexedSource = "class App { void Needle() { } void Call() { Needle(); } }\n"; + TestProjectHelper.InsertIndexedFile(dbPath, "long_line.cs", "csharp", indexedSource); + var oversizedLine = new string('x', LspServer.MaxPositionLineChars + 1) + " Needle();\n"; + File.WriteAllText(sourcePath, oversizedLine); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions(), projectRoot); + var request = CreateDefinitionRequest( + sourcePath, + 3136, + 0, + oversizedLine.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_Definition_HonorsCaseInsensitiveWorkspaceCasing() { From cb5a9b67d96440b7db332c4ba6f4007f833c39de Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 00:59:29 +0900 Subject: [PATCH 09/10] Bound LSP document path fallback candidates (#3137) --- USER_GUIDE.md | 4 +++ changelog.d/unreleased/3137.security.md | 17 ++++++++++ src/CodeIndex/Lsp/LspServer.cs | 3 +- tests/CodeIndex.Tests/LspServerTests.cs | 41 +++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3137.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 688f94e081..4c76440fd2 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1985,6 +1985,8 @@ each `detail` string to 512 characters with `...`, and stops adding symbols before the result array exceeds 524288 JSON bytes. Position-based `definition` and `references` lookups read at most 16384 characters from the target source line before returning an empty result. +When exact indexed path resolution misses, LSP document path fallback inspects +at most 32 basename candidates before treating the document as unresolved. Tool results include structured JSON in `structuredContent` plus a short text summary in `content`, so AI tools can parse typed data without scraping large text blocks. @@ -4263,6 +4265,8 @@ invalid request として拒否します。 `...` 付きの 512 文字に切り詰め、result array が 524288 JSON bytes を超える前に symbol 追加を止めます。 position-based な `definition` / `references` lookup は、対象 source line を最大 16384 文字まで読み、 超過時は空の result を返します。 +exact indexed path resolution が失敗した場合、LSP document path fallback は最大 32 件の +basename candidate だけを確認し、見つからなければ unresolved document として扱います。 ツール結果は `structuredContent` に構造化JSON、`content` に短い要約テキストを返すため、AIツールは巨大なテキストをパースせずに型付きデータを扱えます。 diff --git a/changelog.d/unreleased/3137.security.md b/changelog.d/unreleased/3137.security.md new file mode 100644 index 0000000000..b75c167985 --- /dev/null +++ b/changelog.d/unreleased/3137.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3137 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP document path fallback now caps basename candidates (#3137)** — exact indexed path resolution still wins, while basename fallback stops after a small bounded candidate set. + +## 日本語 + +- **LSP document path fallback の basename candidate 数に上限を追加しました (#3137)** — exact indexed path resolution を優先しつつ、basename fallback は小さな bounded candidate set で打ち切ります。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 339361c988..471ed9d96a 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -25,6 +25,7 @@ internal sealed class LspServer : IDisposable internal const int MaxDocumentSymbolDetailChars = 512; internal const int MaxDocumentSymbolResponseBytes = 512 * 1024; internal const int MaxPositionLineChars = 16 * 1024; + internal const int MaxDocumentPathFallbackCandidates = 32; internal const int MaxUnknownMethodDiagnosticChars = 240; private const int JsonRpcInvalidParamsCode = -32602; private const int JsonRpcInternalErrorCode = -32603; @@ -453,7 +454,7 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string if (string.IsNullOrEmpty(fileName)) return null; - var files = _reader.ListFiles(fileName, 1000); + var files = _reader.ListFiles(fileName, MaxDocumentPathFallbackCandidates); var matches = files .Where(file => MatchesDocumentPath(file.Path, documentPath, projectRelativePath)) .Take(2) diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 5a2c2f87d3..63e80c3c94 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1103,6 +1103,47 @@ public void HandleMessage_Definition_ResolvesIndexedDocumentBeyondBasenameCandid } } + [Fact] + public void HandleMessage_Definition_BasenameFallbackHonorsCandidateCap_Issue3137() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_bounded_basename"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < LspServer.MaxDocumentPathFallbackCandidates; i++) + { + var fillerPath = Path.Combine(projectRoot, "src", i.ToString("D4", CultureInfo.InvariantCulture), "index.cs"); + TestProjectHelper.InsertIndexedFile( + dbPath, + fillerPath, + "csharp", + $"class Filler{i} {{ void Needle() {{ }} }}\n"); + } + + var targetPath = Path.Combine(projectRoot, "src", "9999", "index.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(targetPath, source); + TestProjectHelper.InsertIndexedFile(dbPath, targetPath, "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + var request = CreateDefinitionRequest( + targetPath, + 3137, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => JsonSerializer.Serialize(new { From ec0edd33c7cd7d113513839e66940a8148490035 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:07:08 +0900 Subject: [PATCH 10/10] Cover LSP unknown method truncation (#3205) --- USER_GUIDE.md | 8 ++++-- changelog.d/unreleased/3205.security.md | 16 ++++++++++++ tests/CodeIndex.Tests/LspServerTests.cs | 34 +++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3205.security.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c76440fd2..0a7e1359be 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1975,8 +1975,10 @@ URIs, and are rejected before URI parsing when they exceed 4096 characters, matching the MCP resource URI limit and keeping error responses bounded. LSP frame parsing also rejects more than 64 header lines, more than 65536 aggregate header bytes, any one header line above 8192 bytes, duplicate `Content-Length` -headers, or a body above 8388608 bytes before reading the message body. Request -IDs must be bounded JSON-RPC scalar values: strings are capped at 256 +headers, or a body above 8388608 bytes before reading the message body. +Unknown-method diagnostics echo at most 240 method-name characters with `...` +when the method name is longer. Request IDs must be bounded JSON-RPC scalar +values: strings are capped at 256 characters, integer IDs must fit in `Int64`, and non-scalar IDs are rejected as invalid requests before response IDs are cloned. `workspace/symbol` query strings are capped at 1000 characters before symbol search runs. @@ -4257,6 +4259,8 @@ cdidxには**MCP(Model Context Protocol)サーバー**が組み込まれて LSP frame parsing は、message body を読む前に 64 行を超える header、合計 65536 bytes を 超える header、8192 bytes を超える単一 header 行、重複した `Content-Length` header、 8388608 bytes を超える body を拒否します。 +method-not-found diagnostic で echo する method name は最大 240 文字に制限され、 +長い場合は `...` を付けて切り詰めます。 request ID は bounded な JSON-RPC scalar value に限定され、string は 256 文字まで、 integer ID は `Int64` に収まるものだけを受理し、non-scalar ID は response ID を複製する前に invalid request として拒否します。 diff --git a/changelog.d/unreleased/3205.security.md b/changelog.d/unreleased/3205.security.md new file mode 100644 index 0000000000..fb13c6c880 --- /dev/null +++ b/changelog.d/unreleased/3205.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3205 +affected: + - tests/CodeIndex.Tests/LspServerTests.cs + - USER_GUIDE.md +--- + +## English + +- **LSP unknown-method diagnostics keep method names capped (#3205)** — coverage and documentation now explicitly lock the existing 240-character echo limit for method-not-found responses. + +## 日本語 + +- **LSP unknown-method diagnostic の method name echo 上限を明示しました (#3205)** — method-not-found response が既存の 240 文字上限を維持することを test と documentation で固定しました。 diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 63e80c3c94..a202813a12 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -176,6 +176,40 @@ public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3127() } } + [Fact] + public void HandleMessage_UnknownMethod_TruncatesMethodName_Issue3205() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_unknown_method_3205"); + 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 method = "workspace/" + new string('m', LspServer.MaxUnknownMethodDiagnosticChars + 20) + "LEAK_SENTINEL"; + var request = JsonSerializer.Serialize(new + { + jsonrpc = "2.0", + id = 3205, + method, + }); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + var error = response!["error"]!; + Assert.Equal(-32601, error["code"]!.GetValue()); + var message = error["message"]!.GetValue(); + Assert.StartsWith("Method not found: workspace/", message, StringComparison.Ordinal); + Assert.EndsWith("...", message, StringComparison.Ordinal); + Assert.DoesNotContain("LEAK_SENTINEL", message, StringComparison.Ordinal); + Assert.True(message.Length <= "Method not found: ".Length + LspServer.MaxUnknownMethodDiagnosticChars + "...".Length); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void HandleMessage_UnknownMethod_PreservesSlashDelimitedMethodName_Issue3127() {