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/3062.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: security
issues:
- 3062
affected:
- src/CodeIndex/Cli/SuggestionStore.cs
- tests/CodeIndex.Tests/SuggestionStoreTests.cs
---

## English

- **Suggestion store reads now cap JSON depth and record count (#3062)** — local suggestion store reads reject overly deep JSON and stores with more than the supported record limit, preserving the rejected file as `.bak` instead of allocating an unbounded in-memory record set.

## 日本語

- **suggestion store の読み込みで JSON depth と record 数を制限するようになりました (#3062)** — ローカル suggestion store の読み込みは深すぎる JSON や対応上限を超える record 数のストアを拒否し、無制限のメモリ上 record 集合を作らず対象ファイルを `.bak` として保存します。
112 changes: 61 additions & 51 deletions src/CodeIndex/Cli/SuggestionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ public class SuggestionStore
internal const int DefaultMaxAgeDays = 365;
internal const int DefaultMaxCount = 5000;
internal const int MaxSuggestionStoreBytes = 8 * 1024 * 1024;
internal const int MaxSuggestionStoreJsonDepth = 16;
internal const int MaximumMaxAgeDays = 3650;
internal const int MaximumMaxCount = 100_000;
internal const int MaxSuggestionStoreRecords = MaximumMaxCount;
private const int FuzzyDedupRecentLimit = 100;
private const string RedactedAwsAccessKey = "[REDACTED:aws_access_key]";
private const string RedactedBearerToken = "[REDACTED:bearer_token]";
Expand Down Expand Up @@ -79,6 +81,7 @@ public class SuggestionStore
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
MaxDepth = MaxSuggestionStoreJsonDepth,
};

static SuggestionStore()
Expand Down Expand Up @@ -440,32 +443,14 @@ public void MarkSubmitted(string hash, string issueUrl)
/// </summary>
private List<SuggestionRecord> ReadUnlocked()
{
var ioPath = LongPath.EnsureWindowsPrefix(_filePath);
if (!File.Exists(ioPath))
return new List<SuggestionRecord>();

if (new FileInfo(ioPath).Length == 0)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

var json = DataDirectorySecurity.ReadTextWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare);
if (json is null)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

if (string.IsNullOrWhiteSpace(json))
if (!TryReadStoreSnapshot(out var snapshot))
return new List<SuggestionRecord>();

try
{
var records = JsonSerializer.Deserialize<List<SuggestionRecord>>(json, s_readOptions)
?? new List<SuggestionRecord>();
NormalizeRecordDefaults(records);
return records;
return ReadFilteredSnapshotAsync(snapshot, _ => true, skip: 0, take: null, normalizeDefaults: true)
.GetAwaiter()
.GetResult();
}
catch (JsonException)
{
Expand All @@ -481,15 +466,45 @@ private List<SuggestionRecord> ReadUnlocked()
private static void NormalizeRecordDefaults(List<SuggestionRecord> records)
{
foreach (var record in records)
NormalizeRecordDefaults(record);
}

private static void NormalizeRecordDefaults(SuggestionRecord record)
{
NormalizeLegacyFields(record);
if (string.IsNullOrWhiteSpace(record.CreatedByAgent))
record.CreatedByAgent = "unknown";
if (string.IsNullOrWhiteSpace(record.SessionId))
record.SessionId = "unknown";
if (string.IsNullOrWhiteSpace(record.ClientVersion))
record.ClientVersion = "unknown";
}

private bool TryReadStoreSnapshot(out byte[] snapshot)
{
snapshot = Array.Empty<byte>();
var ioPath = LongPath.EnsureWindowsPrefix(_filePath);
if (!File.Exists(ioPath))
return false;

var readSnapshot = DataDirectorySecurity.ReadBytesWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare);
if (readSnapshot is null)
{
NormalizeLegacyFields(record);
if (string.IsNullOrWhiteSpace(record.CreatedByAgent))
record.CreatedByAgent = "unknown";
if (string.IsNullOrWhiteSpace(record.SessionId))
record.SessionId = "unknown";
if (string.IsNullOrWhiteSpace(record.ClientVersion))
record.ClientVersion = "unknown";
PreserveCorruptFile();
return false;
}

if (readSnapshot.Length == 0)
{
PreserveCorruptFile();
return false;
}

if (IsEmptyOrJsonWhitespace(readSnapshot))
return false;

snapshot = readSnapshot;
return true;
}

private static (SuggestionRecord? Record, double? Score) FindDuplicate(
Expand Down Expand Up @@ -635,29 +650,14 @@ private List<SuggestionRecord> ReadFilteredUnlocked(
int skip = 0,
int? take = null)
{
var ioPath = LongPath.EnsureWindowsPrefix(_filePath);
if (!File.Exists(ioPath))
return new List<SuggestionRecord>();

var snapshot = DataDirectorySecurity.ReadBytesWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare);
if (snapshot is null)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

if (snapshot.Length == 0)
{
PreserveCorruptFile();
return new List<SuggestionRecord>();
}

if (IsEmptyOrJsonWhitespace(snapshot))
if (!TryReadStoreSnapshot(out var snapshot))
return new List<SuggestionRecord>();

try
{
return ReadFilteredSnapshotAsync(snapshot, predicate, skip, take).GetAwaiter().GetResult();
return ReadFilteredSnapshotAsync(snapshot, predicate, skip, take, normalizeDefaults: false)
.GetAwaiter()
.GetResult();
}
catch (JsonException)
{
Expand Down Expand Up @@ -698,27 +698,37 @@ private static async Task<List<SuggestionRecord>> ReadFilteredSnapshotAsync(
byte[] snapshot,
Func<SuggestionRecord, bool> predicate,
int skip,
int? take)
int? take,
bool normalizeDefaults)
{
var results = new List<SuggestionRecord>();
var recordsRead = 0;
var skipped = 0;

await using var stream = new MemoryStream(snapshot, writable: false);

await foreach (var record in JsonSerializer.DeserializeAsyncEnumerable<SuggestionRecord>(stream, s_readOptions))
{
recordsRead++;
if (recordsRead > MaxSuggestionStoreRecords)
throw new JsonException($"Suggestion store contains more than {MaxSuggestionStoreRecords} records.");

if (record == null || !predicate(record))
continue;

if (normalizeDefaults)
NormalizeRecordDefaults(record);

if (skipped < skip)
{
skipped++;
continue;
}

results.Add(record);
if (take.HasValue && results.Count >= take.Value)
break;
continue;

results.Add(record);
}

return results;
Expand Down
81 changes: 81 additions & 0 deletions tests/CodeIndex.Tests/SuggestionStoreTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Text;
using CodeIndex.Cli;
using CodeIndex.Models;

Expand Down Expand Up @@ -108,6 +109,58 @@ public void LoadByStatus_OversizedStore_PreservesBackupAndReturnsEmpty()
Assert.True(File.Exists(path + ".bak"));
}

[Fact]
public void LoadAll_ExcessiveJsonDepth_PreservesBackupAndReturnsEmpty()
{
var path = Path.Combine(_tempDir, "suggestions-codeindex.json");
File.WriteAllText(path, BuildDeepSuggestionStoreJson(SuggestionStore.MaxSuggestionStoreJsonDepth + 1));

var records = _store.LoadAll();

Assert.Empty(records);
Assert.False(File.Exists(path));
Assert.True(File.Exists(path + ".bak"));
}

[Fact]
public void LoadAll_TooManyRecords_PreservesBackupAndReturnsEmpty()
{
var path = Path.Combine(_tempDir, "suggestions-codeindex.json");
WriteEmptyRecordStore(path, SuggestionStore.MaxSuggestionStoreRecords + 1);

var records = _store.LoadAll();

Assert.Empty(records);
Assert.False(File.Exists(path));
Assert.True(File.Exists(path + ".bak"));
}

[Fact]
public void LoadByStatus_TooManyRecordsBeforeFilter_PreservesBackupAndReturnsEmpty()
{
var path = Path.Combine(_tempDir, "suggestions-codeindex.json");
WriteEmptyRecordStore(path, SuggestionStore.MaxSuggestionStoreRecords + 1);

var records = _store.LoadByStatus(SuggestionStatus.SubmittedPendingTriage);

Assert.Empty(records);
Assert.False(File.Exists(path));
Assert.True(File.Exists(path + ".bak"));
}

[Fact]
public void LoadPage_TooManyRecordsAfterPage_PreservesBackupAndReturnsEmpty()
{
var path = Path.Combine(_tempDir, "suggestions-codeindex.json");
WriteEmptyRecordStore(path, SuggestionStore.MaxSuggestionStoreRecords + 1);

var records = _store.Load(skip: 0, take: 1);

Assert.Empty(records);
Assert.False(File.Exists(path));
Assert.True(File.Exists(path + ".bak"));
}

// --- TryAdd tests / TryAdd テスト ---

[Fact]
Expand Down Expand Up @@ -1027,6 +1080,34 @@ public void TryAdd_MoveFailure_DoesNotLeaveOrphanTmpFile()

// --- Helpers / ヘルパー ---

private static string BuildDeepSuggestionStoreJson(int nestedObjectCount)
{
var builder = new StringBuilder();
builder.Append("[{\"category\":\"other\",\"description\":\"Deep suggestion\",\"hash\":\"deep\",\"ignored\":");
for (var i = 0; i < nestedObjectCount; i++)
builder.Append("{\"x\":");
builder.Append("\"value\"");
for (var i = 0; i < nestedObjectCount; i++)
builder.Append('}');
builder.Append("}]");
return builder.ToString();
}

private static void WriteEmptyRecordStore(string path, int count)
{
var builder = new StringBuilder(capacity: (count * 3) + 2);
builder.Append('[');
for (var i = 0; i < count; i++)
{
if (i > 0)
builder.Append(',');
builder.Append("{}");
}

builder.Append(']');
File.WriteAllText(path, builder.ToString());
}

private static SuggestionRecord MakeRecord(string category, string? language, string description)
{
return new SuggestionRecord
Expand Down
Loading