Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/3113.security.md
Original file line number Diff line number Diff line change
@@ -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 を反映したりせず拒否されます。
68 changes: 67 additions & 1 deletion src/CodeIndex/Lsp/LspServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<byte>.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<byte>.Shared.Return(buffer);
}
}

private static string SanitizeUnknownMethod(string method)
{
var wasTruncated = method.Length > MaxUnknownMethodDiagnosticChars;
Expand Down
42 changes: 42 additions & 0 deletions tests/CodeIndex.Tests/LspServerRequestIdTests.cs
Original file line number Diff line number Diff line change
@@ -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<int>());
Assert.Contains(
"Request id must be",
response["error"]!["message"]!.GetValue<string>(),
StringComparison.Ordinal);
Assert.Null(response["id"]);
Assert.DoesNotContain(oversizedId, response.ToJsonString());
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}
}
Loading