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

## English

- **DbPathResolver now ignores indexed-file samples that escape candidate roots (#2866)** — project-root probing for explicit `.cdidx/codeindex.db` paths now normalizes candidate sample paths and skips `../` or absolute-like entries before reading files.

## 日本語

- **DbPathResolver が candidate root 外へ逃げる indexed-file sample を無視するようになりました (#2866)** — 明示指定された `.cdidx/codeindex.db` の project-root 推定で、サンプルpathを正規化し、`../` や絶対path風の entries はファイル読取前にスキップします。
36 changes: 34 additions & 2 deletions src/CodeIndex/Cli/DbPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -508,8 +508,9 @@ private static SampleMatchResult CountMatchingSamples(string candidateRoot, IRea
{
try
{
var absolutePath = Path.Combine(candidateRoot, sample.RelativePath.Replace('/', Path.DirectorySeparatorChar));
var ioPath = LongPath.EnsureWindowsPrefix(absolutePath);
if (!TryResolveIndexedFileSampleIoPath(candidateRoot, sample.RelativePath, out var ioPath))
continue;

if (!File.Exists(ioPath))
continue;

Expand All @@ -532,6 +533,37 @@ private static SampleMatchResult CountMatchingSamples(string candidateRoot, IRea
return new SampleMatchResult(checksumMatches, pathExistsMatches);
}

internal static bool TryResolveIndexedFileSampleIoPath(string candidateRoot, string sampleRelativePath, out string ioPath)
{
ioPath = string.Empty;
if (string.IsNullOrWhiteSpace(sampleRelativePath) || IsRootedOrAbsoluteLikeSamplePath(sampleRelativePath))
return false;

try
{
var normalizedRoot = Path.GetFullPath(candidateRoot);
var relativePath = NormalizeSampleRelativePath(sampleRelativePath);
var absolutePath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath));
if (!IsUnderDirectory(normalizedRoot, absolutePath))
return false;

ioPath = LongPath.EnsureWindowsPrefix(absolutePath);
return true;
}
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException)
{
return false;
}
}

private static bool IsRootedOrAbsoluteLikeSamplePath(string samplePath)
=> Path.IsPathRooted(samplePath);

private static string NormalizeSampleRelativePath(string sampleRelativePath)
=> Path.DirectorySeparatorChar == '\\'
? sampleRelativePath.Replace('/', Path.DirectorySeparatorChar)
: sampleRelativePath;

private readonly record struct SampleMatchResult(int ChecksumMatches, int PathExistsMatches);
private sealed record IndexedFileSample(string RelativePath, string Checksum);
}
Expand Down
118 changes: 118 additions & 0 deletions tests/CodeIndex.Tests/DbPathResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,124 @@ public void ResolveProjectRootForQuery_ExplicitProjectLocalDbDoesNotCaseFoldPers
}
}

[Fact]
public void ResolveProjectRootForQuery_ExplicitProjectLocalDbIgnoresEscapingSampleMatches()
{
var projectParent = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_escape_parent");
var staleParent = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_escape_stale_parent");
var projectRoot = Path.Combine(projectParent, "project");
var staleRoot = Path.Combine(staleParent, "stale");
var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db");
try
{
Directory.CreateDirectory(projectRoot);
Directory.CreateDirectory(staleRoot);
Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!);
Directory.CreateDirectory(Path.Combine(projectParent, "outside"));

const string outsideContent = "class Outside {}\n";
File.WriteAllText(Path.Combine(projectParent, "outside", "outside.cs"), outsideContent);

using (var db = new DbContext(dbPath))
{
db.InitializeSchema();
var writer = new DbWriter(db.Connection);
writer.SetMeta(DbContext.IndexedProjectRootMetaKey, staleRoot);
}
TestProjectHelper.InsertIndexedFile(dbPath, "../outside/outside.cs", "csharp", outsideContent);

var resolved = DbPathResolver.ResolveProjectRootForQuery(dbPath, dbPathExplicit: true);

Assert.Equal(staleRoot, resolved);
}
finally
{
TestProjectHelper.DeleteDirectory(projectParent);
TestProjectHelper.DeleteDirectory(staleParent);
}
}

[Theory]
[InlineData("../outside.cs")]
[InlineData("src/../../outside.cs")]
public void TryResolveIndexedFileSampleIoPath_RejectsEscapingRelativeSamples(string samplePath)
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_escape_sample");
try
{
var resolved = DbPathResolver.TryResolveIndexedFileSampleIoPath(projectRoot, samplePath, out var ioPath);

Assert.False(resolved);
Assert.Equal(string.Empty, ioPath);
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Theory]
[InlineData("/outside.cs")]
public void TryResolveIndexedFileSampleIoPath_RejectsRootedSamples(string samplePath)
{
var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_rooted_sample");
try
{
var resolved = DbPathResolver.TryResolveIndexedFileSampleIoPath(projectRoot, samplePath, out var ioPath);

Assert.False(resolved);
Assert.Equal(string.Empty, ioPath);
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void TryResolveIndexedFileSampleIoPath_OnWindowsRejectsDriveAndUncSamples()
{
if (!OperatingSystem.IsWindows())
return;

var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_windows_absolute_sample");
try
{
foreach (var samplePath in new[] { "\\outside.cs", "C:/outside.cs", @"C:\outside.cs", @"\\server\share\outside.cs" })
{
var resolved = DbPathResolver.TryResolveIndexedFileSampleIoPath(projectRoot, samplePath, out var ioPath);

Assert.False(resolved);
Assert.Equal(string.Empty, ioPath);
}
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void TryResolveIndexedFileSampleIoPath_OnPosixPreservesBackslashInFilename()
{
if (OperatingSystem.IsWindows())
return;

var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_posix_backslash_sample");
try
{
const string samplePath = "back\\slash.py";
var resolved = DbPathResolver.TryResolveIndexedFileSampleIoPath(projectRoot, samplePath, out var ioPath);

Assert.True(resolved);
Assert.Equal(Path.GetFullPath(Path.Combine(projectRoot, samplePath)), ioPath);
}
finally
{
TestProjectHelper.DeleteDirectory(projectRoot);
}
}

[Fact]
public void ResolveProjectRootForQuery_ExplicitProjectLocalReadOnlyUriWithoutMetadataReturnsNull()
{
Expand Down
Loading