From 0a29bb2d1c1470f72e2cadfe01ba0424892b43fb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:05:52 +0900 Subject: [PATCH 1/7] Fix MCP response frame depth guard (#3012) --- changelog.d/unreleased/3012.security.md | 16 +++++++++++ src/CodeIndex/Mcp/McpServer.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 37 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3012.security.md diff --git a/changelog.d/unreleased/3012.security.md b/changelog.d/unreleased/3012.security.md new file mode 100644 index 0000000000..465f8daa99 --- /dev/null +++ b/changelog.d/unreleased/3012.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3012 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP response-frame detection now enforces the JSON depth cap (#3012)** — response-frame probing rejects over-depth JSON before classification, matching the guarded MCP request and cancellation parsing paths. + +## 日本語 + +- **MCP response frame 判定で JSON 深さ上限を適用しました (#3012)** — response frame の事前判定でも過深 JSON を分類前に拒否し、MCP request / cancellation の保護済み parse 経路と揃えました。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index f61e202b88..32a85f6f89 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -833,7 +833,7 @@ private static bool IsServerResponseFrame(string frame) { try { - var node = JsonNode.Parse(frame); + var node = JsonNode.Parse(frame, documentOptions: new JsonDocumentOptions { MaxDepth = MaxJsonDepth }); return node is JsonObject obj && obj.ContainsKey("id") && obj["method"] is null diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 95eb0e1fef..37748bff58 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Net; using System.Net.Sockets; +using System.Reflection; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Indexer; @@ -12074,6 +12075,16 @@ public void ProcessFrame_TooDeepJson_ReturnsParseErrorWithNullId() AssertJsonNullId(response); } + [Fact] + public void IsServerResponseFrame_TooDeepResponse_ReturnsFalse_Issue3012() + { + Assert.True(InvokeIsServerResponseFrame("""{"jsonrpc":"2.0","id":1,"result":{}}""")); + + var frame = BuildNestedJsonRpcResponse(McpServer.MaxJsonDepth + 1); + + Assert.False(InvokeIsServerResponseFrame(frame)); + } + [Fact] public void HandleMessage_BatchMixedRequests_ReturnsResponseArray() { @@ -12152,6 +12163,32 @@ private static void AssertJsonNullId(JsonNode node) Assert.Null(obj["id"]); } + private static bool InvokeIsServerResponseFrame(string frame) + { + var method = typeof(McpServer).GetMethod("IsServerResponseFrame", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + return (bool)method.Invoke(null, [frame])!; + } + + private static string BuildNestedJsonRpcResponse(int nestedObjectCount) + { + var builder = new StringBuilder("""{"jsonrpc":"2.0","id":1,"result":"""); + AppendNestedObject(builder, nestedObjectCount); + builder.Append('}'); + return builder.ToString(); + } + + private static void AppendNestedObject(StringBuilder builder, int nestedObjectCount) + { + for (var i = 0; i < nestedObjectCount; i++) + builder.Append("""{"next":"""); + + builder.Append('0'); + + for (var i = 0; i < nestedObjectCount; i++) + builder.Append('}'); + } + private static void WriteOversizedAsciiFile(string path) { const int targetBytes = 10 * 1024 * 1024 + 1; From ce8e328d18513a5d57089ee9b334aecbcc1a2d79 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:15:17 +0900 Subject: [PATCH 2/7] Cover HTTP MCP id depth guard (#3014) --- changelog.d/unreleased/3014.security.md | 16 ++++++++++++++ .../CodeIndex.Tests/HttpMcpTransportTests.cs | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 changelog.d/unreleased/3014.security.md diff --git a/changelog.d/unreleased/3014.security.md b/changelog.d/unreleased/3014.security.md new file mode 100644 index 0000000000..658e996466 --- /dev/null +++ b/changelog.d/unreleased/3014.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3014 +affected: + - src/CodeIndex/Mcp/HttpMcpTransport.cs + - tests/CodeIndex.Tests/HttpMcpTransportTests.cs +--- + +## English + +- **HTTP MCP JSON-RPC id probing is covered by the JSON depth cap (#3014)** — over-depth HTTP request bodies no longer expose request-id extraction to unbounded JSON parsing work. + +## 日本語 + +- **HTTP MCP の JSON-RPC id 抽出に JSON 深さ上限を適用しました (#3014)** — 過深な HTTP request body によって request id 抽出が無制限の JSON parse 作業を行わないようにしました。 diff --git a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs index 14deef859c..5eb4d243fb 100644 --- a/tests/CodeIndex.Tests/HttpMcpTransportTests.cs +++ b/tests/CodeIndex.Tests/HttpMcpTransportTests.cs @@ -192,6 +192,20 @@ public async Task HttpTransport_RequestLogger_RecordsMethodStatusDurationAndAuth Assert.Equal((int)HttpStatusCode.OK, okPost.StatusCode); } + [Fact] + public async Task HttpTransport_RequestLogger_TooDeepJsonRpcIdReturnsNull_Issue3014() + { + var records = new ConcurrentQueue(); + await using var harness = await McpHttpHarness.StartAsync(_dbPath, requestLogger: records.Enqueue); + + using var response = await harness.PostJsonAsync(BuildNestedJsonRpcRequest(McpServer.MaxJsonDepth + 1)); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var snapshot = await WaitForRequestLogRecordsAsync(records, 1); + var record = Assert.Single(snapshot, record => record.Method == "POST"); + Assert.Null(record.RequestId); + } + [Fact] public async Task HttpTransport_TwoSequentialRequests_ShareWarmServer() { @@ -819,6 +833,14 @@ private static string BuildNestedJsonRpcResponse(int nestedObjectCount) return builder.ToString(); } + private static string BuildNestedJsonRpcRequest(int nestedObjectCount) + { + var builder = new StringBuilder("""{"jsonrpc":"2.0","id":1,"method":"ping","params":"""); + AppendNestedObject(builder, nestedObjectCount); + builder.Append('}'); + return builder.ToString(); + } + private static void AppendNestedObject(StringBuilder builder, int nestedObjectCount) { for (var i = 0; i < nestedObjectCount; i++) From 1553546b8729fde24a74067285343188c9d90283 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:24:06 +0900 Subject: [PATCH 3/7] Cap JSON envelope raw item depth (#3016) --- changelog.d/unreleased/3016.security.md | 16 ++++++++ src/CodeIndex/Cli/JsonEnvelopeWrapper.cs | 3 +- .../JsonEnvelopeWrapperTests.cs | 38 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3016.security.md diff --git a/changelog.d/unreleased/3016.security.md b/changelog.d/unreleased/3016.security.md new file mode 100644 index 0000000000..d7cf6704d8 --- /dev/null +++ b/changelog.d/unreleased/3016.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3016 +affected: + - src/CodeIndex/Cli/JsonEnvelopeWrapper.cs + - tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs +--- + +## English + +- **JSON envelope raw-item parsing now caps JSON depth (#3016)** — `--json-envelope` keeps over-depth raw output lines as strings instead of parsing them without an explicit nesting limit. + +## 日本語 + +- **JSON envelope の raw item parse に JSON 深さ上限を適用しました (#3016)** — `--json-envelope` は過深な raw output 行を、明示的な入れ子上限なしで parse せず従来どおり文字列として保持します。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs index 969efe2b94..c42cd82b7f 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs @@ -18,6 +18,7 @@ internal static class JsonEnvelopeWrapper { internal const string EnvelopeFlag = "--json-envelope"; internal const int MaxCapturedOutputChars = 10 * 1024 * 1024; + internal const int MaxRawJsonItemDepth = 32; private static readonly HashSet WrappableCommands = new(StringComparer.Ordinal) { @@ -210,7 +211,7 @@ private static JsonArray ParseRawJsonItems(string raw) JsonNode? node; try { - node = JsonNode.Parse(line); + node = JsonNode.Parse(line, documentOptions: new JsonDocumentOptions { MaxDepth = MaxRawJsonItemDepth }); } catch (JsonException) { diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs index 8392774631..9dffb1cf2e 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.Json; using CodeIndex.Cli; @@ -268,6 +269,29 @@ public void RunWrapped_CapturedOutputExceedsLimit_ReturnsJsonErrorEnvelope_Issue Assert.Equal(0, document.RootElement.GetProperty("results").GetArrayLength()); } + [Fact] + public void RunWrapped_TooDeepRawJsonItem_KeepsLineAsString_Issue3016() + { + var rawLine = BuildNestedRawJson(JsonEnvelopeWrapper.MaxRawJsonItemDepth + 1); + var (exitCode, stdout, stderr) = CaptureConsole(() => JsonEnvelopeWrapper.RunWrapped( + "search", + ["Needle", "--json-envelope"], + "1.0.0", + _jsonOptions, + _ => + { + Console.WriteLine(rawLine); + return CommandExitCodes.Success; + })); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var result = Assert.Single(document.RootElement.GetProperty("results").EnumerateArray()); + Assert.Equal(JsonValueKind.String, result.ValueKind); + Assert.Equal(rawLine, result.GetString()); + } + [Fact] public void Symbols_WithEnvelope_NormalizesQueryFromExtraNames() { @@ -298,4 +322,18 @@ public void Symbols_WithEnvelope_NormalizesQueryFromExtraNames() private static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) => ConsoleCapture.Capture(action); + + private static string BuildNestedRawJson(int nestedObjectCount) + { + var builder = new StringBuilder("""{"value":"""); + for (var i = 0; i < nestedObjectCount; i++) + builder.Append("""{"next":"""); + + builder.Append('0'); + + for (var i = 0; i < nestedObjectCount; i++) + builder.Append('}'); + builder.Append('}'); + return builder.ToString(); + } } From 69e9205add80eb775d1f823d125cf452e97a53d6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:30:03 +0900 Subject: [PATCH 4/7] Cap LSP JSON message depth (#3021) --- changelog.d/unreleased/3021.security.md | 16 +++++++++++ src/CodeIndex/Lsp/LspServer.cs | 10 +++++-- tests/CodeIndex.Tests/LspServerTests.cs | 36 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3021.security.md diff --git a/changelog.d/unreleased/3021.security.md b/changelog.d/unreleased/3021.security.md new file mode 100644 index 0000000000..f65913cdb1 --- /dev/null +++ b/changelog.d/unreleased/3021.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3021 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP JSON message parsing now enforces a depth cap (#3021)** — deeply nested LSP payloads are rejected as parse errors instead of being parsed without an explicit nesting limit. + +## 日本語 + +- **LSP JSON message parse に深さ上限を適用しました (#3021)** — 過深な LSP payload は、明示的な入れ子上限なしで parse されず parse error として拒否されます。 diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 71641eaceb..dd86becd23 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -15,6 +15,12 @@ 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 MaxJsonDepth = 32; + private static readonly JsonDocumentOptions LspJsonDocumentOptions = new() + { + MaxDepth = MaxJsonDepth, + }; + private readonly DbReader _reader; private readonly string _version; private readonly JsonSerializerOptions _jsonOptions; @@ -54,7 +60,7 @@ public int Run(Stream input, Stream output) JsonDocument document; try { - document = JsonDocument.Parse(payload); + document = JsonDocument.Parse(payload, LspJsonDocumentOptions); } catch (JsonException) { @@ -74,7 +80,7 @@ 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()) : null; + id = hasId ? JsonNode.Parse(idElement.GetRawText(), documentOptions: LspJsonDocumentOptions) : null; if (method == null) return hasId ? Error(id, -32600, "Invalid Request") : null; diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index 001f5aaf20..013906373e 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -83,6 +83,28 @@ public void HandleMessage_Initialize_AdvertisesCoreCapabilities() } } + [Fact] + public void HandleMessage_TooDeepJson_ReturnsParseError_Issue3021() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_depth"); + 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(BuildNestedLspRequest(LspServer.MaxJsonDepth + 1)); + + Assert.NotNull(response); + Assert.Equal(-32700, response!["error"]!["code"]!.GetValue()); + Assert.Null(response["id"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_MalformedJsonFrame_WritesParseErrorAndContinues() { @@ -667,6 +689,20 @@ private static int CharacterOf(string source, int line, string value) return lines[line].IndexOf(value, StringComparison.Ordinal); } + private static string BuildNestedLspRequest(int nestedObjectCount) + { + var builder = new StringBuilder("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":"""); + for (var i = 0; i < nestedObjectCount; i++) + builder.Append("""{"next":"""); + + builder.Append('0'); + + for (var i = 0; i < nestedObjectCount; i++) + builder.Append('}'); + builder.Append('}'); + return builder.ToString(); + } + private static void MarkGraphReady(string dbPath) { using var db = new DbContext(dbPath); From 7669d714304b9d28378a0506a0b3b698f64fe8fd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:34:23 +0900 Subject: [PATCH 5/7] Cap batch query JSON depth (#3022) --- changelog.d/unreleased/3022.security.md | 16 ++++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 8 +++++- .../QueryCommandRunnerTests.cs | 25 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3022.security.md diff --git a/changelog.d/unreleased/3022.security.md b/changelog.d/unreleased/3022.security.md new file mode 100644 index 0000000000..f4a92cdf54 --- /dev/null +++ b/changelog.d/unreleased/3022.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3022 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +--- + +## English + +- **Batch query JSON input now enforces a depth cap (#3022)** — `cdidx batch` rejects over-depth JSON lines during parsing before command argument processing. + +## 日本語 + +- **batch query の JSON 入力に深さ上限を適用しました (#3022)** — `cdidx batch` は過深な JSON 行を command argument 処理前の parse 段階で拒否します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4fd7dc0883..82ea2cdd36 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -24,6 +24,7 @@ public static class QueryCommandRunner internal const int DefaultImpactLimit = 50; internal const int BatchMaxLineChars = 1024 * 1024; internal const int BatchMaxArgumentCount = 256; + internal const int BatchMaxJsonDepth = 32; internal const string DefaultLimitEnvironmentVariable = "CDIDX_DEFAULT_LIMIT"; internal const string DefaultSnippetLinesEnvironmentVariable = "CDIDX_DEFAULT_SNIPPET_LINES"; internal const string DefaultMaxLineWidthEnvironmentVariable = "CDIDX_DEFAULT_MAX_LINE_WIDTH"; @@ -33,6 +34,11 @@ public static class QueryCommandRunner private const string LanguageCapabilitySymbols = "symbols"; internal static readonly TimeSpan DefaultStaleAfter = TimeSpan.FromHours(24); internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; + private static readonly JsonDocumentOptions BatchJsonDocumentOptions = new() + { + MaxDepth = BatchMaxJsonDepth, + }; + [ThreadStatic] private static DbReader? s_batchReader; @@ -371,7 +377,7 @@ private static bool TryParseBatchLine(string line, int lineNumber, out string co try { - using var document = JsonDocument.Parse(line); + using var document = JsonDocument.Parse(line, BatchJsonDocumentOptions); if (document.RootElement.ValueKind != JsonValueKind.Array || document.RootElement.GetArrayLength() == 0) { Console.Error.WriteLine($"Error: batch line {lineNumber} must be a non-empty JSON string array."); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 940a471005..59bc85ef68 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -666,6 +666,31 @@ public void RunBatch_ArgumentCountExceedsLimit_ReturnsUsageError_Issue2891() } } + [Fact] + public void RunBatch_TooDeepJsonLine_ReturnsUsageError_Issue3022() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_batch_json_depth"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var nestedPrefix = string.Concat(Enumerable.Repeat("""{"next":""", QueryCommandRunner.BatchMaxJsonDepth + 1)); + var nested = nestedPrefix + "0" + new string('}', QueryCommandRunner.BatchMaxJsonDepth + 1); + var input = $$"""["status",{{nested}}]""" + "\n"; + + var (exitCode, stdout, stderr) = CaptureConsoleWithInput( + input, + () => QueryCommandRunner.RunBatch(["--db", dbPath], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("is not valid JSON", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ParseArgs_ImpactDepthZeroIsRetainedWhenExplicit() { From b57b5c2f9d3aa9873e748aca054611fbd398f21f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:38:36 +0900 Subject: [PATCH 6/7] Cap active workspace JSON depth (#3036) --- changelog.d/unreleased/3036.security.md | 16 +++++++ src/CodeIndex/Cli/ActiveWorkspace.cs | 7 +++- .../WorkspaceCommandRunnerTests.cs | 42 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3036.security.md diff --git a/changelog.d/unreleased/3036.security.md b/changelog.d/unreleased/3036.security.md new file mode 100644 index 0000000000..efa634f7ca --- /dev/null +++ b/changelog.d/unreleased/3036.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3036 +affected: + - src/CodeIndex/Cli/ActiveWorkspace.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +--- + +## English + +- **Active workspace state parsing now enforces a JSON depth cap (#3036)** — deeply nested `active.json` files are ignored with the existing warning path instead of overriding workspace DB resolution. + +## 日本語 + +- **active workspace state の parse に JSON 深さ上限を適用しました (#3036)** — 過深な `active.json` は既存の warning 経路で無視され、workspace DB 解決を上書きしません。 diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index a57ecc9fb6..8ba2810336 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -9,6 +9,11 @@ internal static class ActiveWorkspace { internal const string EnvironmentVariable = "CDIDX_ACTIVE_WORKSPACE"; private const int MaxStateBytes = 64 * 1024; + internal const int MaxStateJsonDepth = 16; + private static readonly JsonDocumentOptions StateJsonDocumentOptions = new() + { + MaxDepth = MaxStateJsonDepth, + }; internal static string StatePath { @@ -41,7 +46,7 @@ internal static string StatePath return null; } - using var document = JsonDocument.Parse(text); + using var document = JsonDocument.Parse(text, StateJsonDocumentOptions); var root = document.RootElement; var name = ReadString(root, "name") ?? "default"; var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 8566f14b58..0f8ffd8aa1 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -359,6 +359,48 @@ public void MalformedActiveWorkspaceState_DoesNotOverrideQueryResolution() } } + [Fact] + public void DeeplyNestedActiveWorkspaceState_DoesNotOverrideQueryResolution_Issue3036() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_depth_project"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_depth_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + var activeDbPath = Path.Combine(configHome, "active.db"); + var nestedPrefix = string.Concat(Enumerable.Repeat("""{"next":""", ActiveWorkspace.MaxStateJsonDepth + 1)); + var nested = nestedPrefix + "0" + new string('}', ActiveWorkspace.MaxStateJsonDepth + 1); + File.WriteAllText(ActiveWorkspace.StatePath, $$""" + { + "name": "active", + "root": {{JsonSerializer.Serialize(configHome)}}, + "db_path": {{JsonSerializer.Serialize(activeDbPath)}}, + "extra": {{nested}} + } + """); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace state", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void ActiveWorkspaceSave_OnPosix_WritesPrivateStateFile() { From cbb55cfa4c145b1c75a5f12e48e4306945723149 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 5 Jun 2026 02:43:47 +0900 Subject: [PATCH 7/7] Cap MCP index lock metadata depth (#3043) --- changelog.d/unreleased/3043.security.md | 16 ++++++++ src/CodeIndex/Mcp/McpIndexRunLock.cs | 7 +++- tests/CodeIndex.Tests/McpServerTests.cs | 52 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3043.security.md diff --git a/changelog.d/unreleased/3043.security.md b/changelog.d/unreleased/3043.security.md new file mode 100644 index 0000000000..2f816a35c3 --- /dev/null +++ b/changelog.d/unreleased/3043.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3043 +affected: + - src/CodeIndex/Mcp/McpIndexRunLock.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP index-run lock metadata parsing now enforces a JSON depth cap (#3043)** — deeply nested lock holder metadata is treated as unavailable diagnostics instead of being parsed without an explicit nesting limit. + +## 日本語 + +- **MCP index-run lock metadata の parse に JSON 深さ上限を適用しました (#3043)** — 過深な lock holder metadata は、明示的な入れ子上限なしで parse せず diagnostic 不明として扱います。 diff --git a/src/CodeIndex/Mcp/McpIndexRunLock.cs b/src/CodeIndex/Mcp/McpIndexRunLock.cs index 528ab3b610..c49f771057 100644 --- a/src/CodeIndex/Mcp/McpIndexRunLock.cs +++ b/src/CodeIndex/Mcp/McpIndexRunLock.cs @@ -8,7 +8,12 @@ internal sealed class McpIndexRunLock : IDisposable { internal const string LockFileName = "index.lock"; private const int MaxInfoBytes = 4 * 1024; + internal const int MaxInfoJsonDepth = 16; private static readonly TimeSpan StaleInfoGracePeriod = TimeSpan.FromSeconds(2); + private static readonly JsonDocumentOptions InfoJsonDocumentOptions = new() + { + MaxDepth = MaxInfoJsonDepth, + }; private readonly FileStream _stream; private readonly string _infoPath; @@ -97,7 +102,7 @@ private static string BuildBusyMessage(string infoPath) if (string.IsNullOrWhiteSpace(text)) return null; - using var document = JsonDocument.Parse(text); + using var document = JsonDocument.Parse(text, InfoJsonDocumentOptions); var root = document.RootElement; if (!root.TryGetProperty("pid", out var pidElement) || !pidElement.TryGetInt32(out var pid)) return null; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 37748bff58..8a2dd5a190 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -8120,6 +8120,58 @@ public void ToolsCall_Index_WhenDbLockInfoTooLarge_ReturnsBusyWithoutHolderDetai } } + [Fact] + public void ToolsCall_Index_WhenDbLockInfoTooDeep_ReturnsBusyWithoutHolderDetails_Issue3043() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_deep_lock_fixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_deep_lock_{Guid.NewGuid():N}.db"); + var lockPath = McpIndexRunLock.ResolveLockPath(dbPath); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var infoPath = lockPath + ".info"; + var info = new StringBuilder($$"""{"pid":{{Environment.ProcessId}},"since":"2026-01-02T03:04:05.0000000+00:00","extra":"""); + AppendNestedObject(info, McpIndexRunLock.MaxInfoJsonDepth + 1); + info.Append('}'); + File.WriteAllText(infoPath, info.ToString()); + using var heldLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + try + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir + } + } + }; + + var response = server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("index already running on this DB", text); + Assert.Contains("holder metadata unavailable", text); + Assert.DoesNotContain($"pid {Environment.ProcessId}", text); + } + finally + { + heldLock.Dispose(); + File.Delete(infoPath); + File.Delete(lockPath); + if (Directory.Exists(fixtureDir)) + Directory.Delete(fixtureDir, recursive: true); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void ToolsCall_Index_NonexistentDir_ReturnsError() {