diff --git a/changelog.d/unreleased/2866.security.md b/changelog.d/unreleased/2866.security.md new file mode 100644 index 0000000000..9129c6e2f5 --- /dev/null +++ b/changelog.d/unreleased/2866.security.md @@ -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 はファイル読取前にスキップします。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index f730b3921d..357b70f30c 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -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; @@ -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); } diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 47cff4fbb8..3905fff8ce 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -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() {