From 3704a8f246657ea02ce1c6a74dfee38644892163 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 12:30:50 +0900 Subject: [PATCH 1/2] Fix DbPathResolver sample path validation (#2866) --- changelog.d/unreleased/2866.security.md | 16 ++++ src/CodeIndex/Cli/DbPathResolver.cs | 46 +++++++++++- tests/CodeIndex.Tests/DbPathResolverTests.cs | 78 ++++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2866.security.md 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..4fbf66e33d 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,47 @@ 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 = sampleRelativePath + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + 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) + { + if (Path.IsPathRooted(samplePath)) + return true; + + if (samplePath.StartsWith("/", StringComparison.Ordinal) || samplePath.StartsWith("\\", StringComparison.Ordinal)) + return true; + + return samplePath.Length >= 2 + && IsAsciiLetter(samplePath[0]) + && samplePath[1] == ':'; + } + + private static bool IsAsciiLetter(char value) + => value is >= 'a' and <= 'z' or >= 'A' and <= 'Z'; + 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..477555cc0b 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -358,6 +358,84 @@ 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")] + [InlineData("\\outside.cs")] + [InlineData("C:/outside.cs")] + [InlineData(@"C:\outside.cs")] + [InlineData(@"\\server\share\outside.cs")] + public void TryResolveIndexedFileSampleIoPath_RejectsAbsoluteLikeSamples(string samplePath) + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_absolute_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 ResolveProjectRootForQuery_ExplicitProjectLocalReadOnlyUriWithoutMetadataReturnsNull() { From 0e82e5a06dbd617c691b50358d3563bb359164b3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 12:38:03 +0900 Subject: [PATCH 2/2] Preserve POSIX sample path backslashes (#2866) --- src/CodeIndex/Cli/DbPathResolver.cs | 22 +++------ tests/CodeIndex.Tests/DbPathResolverTests.cs | 52 +++++++++++++++++--- 2 files changed, 52 insertions(+), 22 deletions(-) diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 4fbf66e33d..357b70f30c 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -542,9 +542,7 @@ internal static bool TryResolveIndexedFileSampleIoPath(string candidateRoot, str try { var normalizedRoot = Path.GetFullPath(candidateRoot); - var relativePath = sampleRelativePath - .Replace('/', Path.DirectorySeparatorChar) - .Replace('\\', Path.DirectorySeparatorChar); + var relativePath = NormalizeSampleRelativePath(sampleRelativePath); var absolutePath = Path.GetFullPath(Path.Combine(normalizedRoot, relativePath)); if (!IsUnderDirectory(normalizedRoot, absolutePath)) return false; @@ -559,20 +557,12 @@ internal static bool TryResolveIndexedFileSampleIoPath(string candidateRoot, str } private static bool IsRootedOrAbsoluteLikeSamplePath(string samplePath) - { - if (Path.IsPathRooted(samplePath)) - return true; - - if (samplePath.StartsWith("/", StringComparison.Ordinal) || samplePath.StartsWith("\\", StringComparison.Ordinal)) - return true; - - return samplePath.Length >= 2 - && IsAsciiLetter(samplePath[0]) - && samplePath[1] == ':'; - } + => Path.IsPathRooted(samplePath); - private static bool IsAsciiLetter(char value) - => value is >= 'a' and <= 'z' or >= 'A' and <= 'Z'; + 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 477555cc0b..3905fff8ce 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -416,13 +416,9 @@ public void TryResolveIndexedFileSampleIoPath_RejectsEscapingRelativeSamples(str [Theory] [InlineData("/outside.cs")] - [InlineData("\\outside.cs")] - [InlineData("C:/outside.cs")] - [InlineData(@"C:\outside.cs")] - [InlineData(@"\\server\share\outside.cs")] - public void TryResolveIndexedFileSampleIoPath_RejectsAbsoluteLikeSamples(string samplePath) + public void TryResolveIndexedFileSampleIoPath_RejectsRootedSamples(string samplePath) { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_absolute_sample"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_rooted_sample"); try { var resolved = DbPathResolver.TryResolveIndexedFileSampleIoPath(projectRoot, samplePath, out var ioPath); @@ -436,6 +432,50 @@ public void TryResolveIndexedFileSampleIoPath_RejectsAbsoluteLikeSamples(string } } + [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() {