From 8958637fb7cd50ce7855dff17292ce3426dfc55d Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:19:11 +0900 Subject: [PATCH] Cap TypeScript path alias parsing work --- changelog.d/unreleased/3069.security.md | 16 ++ .../SymbolExtractor.TypeScriptPathAliases.cs | 110 +++++++++- tests/CodeIndex.Tests/SymbolExtractorTests.cs | 188 ++++++++++++++++++ 3 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3069.security.md diff --git a/changelog.d/unreleased/3069.security.md b/changelog.d/unreleased/3069.security.md new file mode 100644 index 0000000000..abf12c6d25 --- /dev/null +++ b/changelog.d/unreleased/3069.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3069 +affected: + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs +--- + +## English + +- **TypeScript path alias configs now bound path rule and target work (#3069)** — `compilerOptions.paths` loading and resolution now cap accepted alias rules, target entries, alias/target string lengths, oversized module specifiers, and substituted target lengths, with bounded warnings when excess entries are truncated, ignored, or skipped. + +## 日本語 + +- **TypeScript path alias 設定の rule / target 処理量を制限しました (#3069)** — `compilerOptions.paths` の読み込みと解決で受理する alias rule 数、target entry 数、alias / target 文字列長、過大な module specifier、置換後 target 長に上限を設け、超過分を切り詰める・無視する・スキップする場合は bounded warning を出すようにしました。 diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs index 16dc805524..5794f75ef6 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs @@ -9,6 +9,13 @@ public static partial class SymbolExtractor private const int MaxTypeScriptPathAliasTotalConfigBytes = 512 * 1024; private const int MaxTypeScriptPathAliasExtendsDepth = 8; private const int MaxTypeScriptPathAliasConfigJsonDepth = 32; + private const int MaxTypeScriptPathAliasRules = 1024; + private const int MaxTypeScriptPathAliasTargetsPerRule = 32; + private const int MaxTypeScriptPathAliasTotalTargets = 2048; + private const int MaxTypeScriptPathAliasPatternLength = 512; + private const int MaxTypeScriptPathAliasTargetLength = 1024; + private const int MaxTypeScriptPathAliasModuleSpecifierLength = 4096; + private const int MaxTypeScriptPathAliasSubstitutedTargetLength = 4096; private static readonly object TypeScriptPathAliasWarningLock = new(); private static readonly HashSet TypeScriptPathAliasReportedWarnings = new(StringComparer.Ordinal); private static readonly JsonDocumentOptions TypeScriptPathAliasConfigJsonOptions = new() @@ -18,7 +25,7 @@ public static partial class SymbolExtractor MaxDepth = MaxTypeScriptPathAliasConfigJsonDepth, }; - private sealed record TypeScriptPathAliasConfig(string ProjectDirectory, string BaseDirectory, bool HasBaseUrl, IReadOnlyList Rules); + private sealed record TypeScriptPathAliasConfig(string ConfigPath, string ProjectDirectory, string BaseDirectory, bool HasBaseUrl, IReadOnlyList Rules); private sealed record TypeScriptPathAliasRule(string Pattern, string BaseDirectory, IReadOnlyList Targets); @@ -40,6 +47,13 @@ private static string ResolveJavaScriptTypeScriptModuleSpecifier(string lang, st if (config == null) return moduleName; + if (moduleName.Length > MaxTypeScriptPathAliasModuleSpecifierLength) + { + ReportTypeScriptPathAliasWarningOnce( + $"Skipped TypeScript path alias resolution in config {config.ConfigPath} for module specifiers longer than {MaxTypeScriptPathAliasModuleSpecifierLength} characters."); + return moduleName; + } + foreach (var rule in config.Rules) { if (!TryMatchTypeScriptPathAliasPattern(rule.Pattern, moduleName, out var wildcard)) @@ -47,9 +61,13 @@ private static string ResolveJavaScriptTypeScriptModuleSpecifier(string lang, st foreach (var target in rule.Targets) { - var substituted = target.Contains('*', StringComparison.Ordinal) - ? target.Replace("*", wildcard, StringComparison.Ordinal) - : target; + if (!TrySubstituteTypeScriptPathAliasTarget(target, wildcard, out var substituted)) + { + ReportTypeScriptPathAliasWarningOnce( + $"Skipped TypeScript path alias target substitution in config {config.ConfigPath} longer than {MaxTypeScriptPathAliasSubstitutedTargetLength} characters."); + continue; + } + var candidate = Path.IsPathRooted(substituted) ? substituted : Path.Combine(rule.BaseDirectory, substituted); @@ -173,30 +191,92 @@ private static string ResolveJavaScriptTypeScriptModuleSpecifier(string lang, st && pathsElement.ValueKind == JsonValueKind.Object) { rules.Clear(); + var totalTargets = 0; + var rulesTruncated = false; + var targetsTruncated = false; + var ignoredLongPattern = false; + var ignoredLongTarget = false; foreach (var property in pathsElement.EnumerateObject()) { + if (rules.Count >= MaxTypeScriptPathAliasRules) + { + rulesTruncated = true; + break; + } + + if (totalTargets >= MaxTypeScriptPathAliasTotalTargets) + { + targetsTruncated = true; + break; + } + if (property.Value.ValueKind != JsonValueKind.Array) continue; + if (property.Name.Length > MaxTypeScriptPathAliasPatternLength) + { + ignoredLongPattern = true; + continue; + } + var targets = new List(); foreach (var item in property.Value.EnumerateArray()) { + if (targets.Count >= MaxTypeScriptPathAliasTargetsPerRule + || totalTargets >= MaxTypeScriptPathAliasTotalTargets) + { + targetsTruncated = true; + break; + } + if (item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString())) { - targets.Add(item.GetString()!); + var target = item.GetString()!; + if (target.Length > MaxTypeScriptPathAliasTargetLength) + { + ignoredLongTarget = true; + continue; + } + + targets.Add(target); + totalTargets++; } } if (targets.Count > 0) rules.Add(new TypeScriptPathAliasRule(property.Name, baseDirectory, targets)); } + + if (rulesTruncated) + { + ReportTypeScriptPathAliasWarningOnce( + $"Truncated TypeScript path alias rules in config {configPath} to {MaxTypeScriptPathAliasRules} entries."); + } + + if (targetsTruncated) + { + ReportTypeScriptPathAliasWarningOnce( + $"Truncated TypeScript path alias targets in config {configPath} to {MaxTypeScriptPathAliasTotalTargets} total entries and {MaxTypeScriptPathAliasTargetsPerRule} entries per rule."); + } + + if (ignoredLongPattern) + { + ReportTypeScriptPathAliasWarningOnce( + $"Ignored TypeScript path alias rules in config {configPath} with patterns longer than {MaxTypeScriptPathAliasPatternLength} characters."); + } + + if (ignoredLongTarget) + { + ReportTypeScriptPathAliasWarningOnce( + $"Ignored TypeScript path alias targets in config {configPath} longer than {MaxTypeScriptPathAliasTargetLength} characters."); + } } } return rules.Count == 0 && !hasBaseUrl ? null - : new TypeScriptPathAliasConfig(configDirectory, baseDirectory, hasBaseUrl, SortTypeScriptPathAliasRules(rules)); + : new TypeScriptPathAliasConfig(configPath, configDirectory, baseDirectory, hasBaseUrl, SortTypeScriptPathAliasRules(rules)); } } @@ -336,6 +416,24 @@ private static bool TryMatchTypeScriptPathAliasPattern(string pattern, string mo return true; } + private static bool TrySubstituteTypeScriptPathAliasTarget(string target, string wildcard, out string substituted) + { + substituted = string.Empty; + var starCount = target.Count(static ch => ch == '*'); + if (starCount == 0) + { + substituted = target; + return true; + } + + var substitutedLength = (long)target.Length - starCount + (long)wildcard.Length * starCount; + if (substitutedLength > MaxTypeScriptPathAliasSubstitutedTargetLength) + return false; + + substituted = target.Replace("*", wildcard, StringComparison.Ordinal); + return true; + } + private static bool TryResolveTypeScriptModuleFile(string candidate, out string resolvedPath) { foreach (var path in EnumerateTypeScriptModuleCandidates(candidate)) diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index 6fb0cf07cc..dabaf29920 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -18193,6 +18193,187 @@ public void Extract_TypeScript_TsconfigExtendsTotalBytesCapSkipsInheritedPathAli } } + [Fact] + public void Extract_TypeScript_ExcessiveTsconfigPathAliasRulesTruncatesWithWarning() + { + var projectRoot = TestProjectHelper.CreateTempProject("tsconfig_alias_many_rules_symbols"); + try + { + var maxRules = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasRules"); + var paths = new StringBuilder(); + for (var i = 0; i < maxRules; i++) + { + if (i > 0) + paths.Append(','); + paths.Append('"').Append("@skip").Append(i).Append("/*").Append("\":[\"missing").Append(i).Append("/*\"]"); + } + + paths.Append(",\"@hit/*\":[\"src/*\"]"); + + WriteFile( + projectRoot, + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{" + paths + "}}}"); + WriteFile(projectRoot, "src/Button.ts", "export const Button = 1;\n"); + var sourcePath = WriteFile(projectRoot, "src/main.ts", "import { Button } from \"@hit/Button\";\n"); + + List symbols = []; + var stderr = ConsoleCapture.CaptureError(() => + symbols = SymbolExtractor.Extract(1, "typescript", File.ReadAllText(sourcePath), sourcePath)); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "@hit/Button"); + Assert.DoesNotContain(symbols, s => s.Kind == "import" && s.Name == "src/Button.ts"); + Assert.Contains("Truncated TypeScript path alias rules", stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Extract_TypeScript_ExcessiveTsconfigPathAliasTargetsTruncatesWithWarning() + { + var projectRoot = TestProjectHelper.CreateTempProject("tsconfig_alias_many_targets_symbols"); + try + { + var maxTargets = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasTargetsPerRule"); + var targets = new StringBuilder(); + for (var i = 0; i < maxTargets; i++) + { + if (i > 0) + targets.Append(','); + targets.Append('"').Append("missing").Append(i).Append("/*").Append('"'); + } + + targets.Append(",\"src/*\""); + + WriteFile( + projectRoot, + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@hit/*\":[" + targets + "]}}}"); + WriteFile(projectRoot, "src/Button.ts", "export const Button = 1;\n"); + var sourcePath = WriteFile(projectRoot, "src/main.ts", "import { Button } from \"@hit/Button\";\n"); + + List symbols = []; + var stderr = ConsoleCapture.CaptureError(() => + symbols = SymbolExtractor.Extract(1, "typescript", File.ReadAllText(sourcePath), sourcePath)); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "@hit/Button"); + Assert.DoesNotContain(symbols, s => s.Kind == "import" && s.Name == "src/Button.ts"); + Assert.Contains("Truncated TypeScript path alias targets", stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Extract_TypeScript_OverlongTsconfigPathAliasStringsAreIgnoredWithBoundedWarning() + { + var projectRoot = TestProjectHelper.CreateTempProject("tsconfig_alias_long_strings_symbols"); + try + { + var maxPatternLength = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasPatternLength"); + var maxTargetLength = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasTargetLength"); + var longPatternPrefix = "@" + new string('a', maxPatternLength); + var longPattern = longPatternPrefix + "/*"; + var longTarget = "src/" + new string('b', maxTargetLength + 1) + "/*"; + WriteFile( + projectRoot, + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"" + + longPattern + + "\":[\"src/*\"],\"@longtarget/*\":[\"" + + longTarget + + "\"]}}}"); + WriteFile(projectRoot, "src/Button.ts", "export const Button = 1;\n"); + var sourcePath = WriteFile(projectRoot, "src/main.ts", """ + import { Button } from "__LONG_PATTERN__/Button"; + import { Other } from "@longtarget/Other"; + """.Replace("__LONG_PATTERN__", longPatternPrefix, StringComparison.Ordinal)); + + List symbols = []; + var stderr = ConsoleCapture.CaptureError(() => + symbols = SymbolExtractor.Extract(1, "typescript", File.ReadAllText(sourcePath), sourcePath)); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == longPatternPrefix + "/Button"); + Assert.DoesNotContain(symbols, s => s.Kind == "import" && s.Name == "src/Button.ts"); + Assert.Contains("Ignored TypeScript path alias rules", stderr, StringComparison.Ordinal); + Assert.Contains("Ignored TypeScript path alias targets", stderr, StringComparison.Ordinal); + Assert.DoesNotContain(longPattern, stderr, StringComparison.Ordinal); + Assert.DoesNotContain(longTarget, stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Extract_TypeScript_OverlongPathAliasModuleSpecifierSkipsResolutionWithBoundedWarning() + { + var projectRoot = TestProjectHelper.CreateTempProject("tsconfig_alias_long_module_symbols"); + try + { + var maxModuleSpecifierLength = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasModuleSpecifierLength"); + var longModuleName = "@/" + new string('a', maxModuleSpecifierLength + 1); + WriteFile( + projectRoot, + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@/*\":[\"src/*\"]}}}"); + var sourcePath = WriteFile(projectRoot, "src/main.ts", "import value from \"" + longModuleName + "\";\n"); + + List symbols = []; + var stderr = ConsoleCapture.CaptureError(() => + symbols = SymbolExtractor.Extract(1, "typescript", File.ReadAllText(sourcePath), sourcePath)); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == longModuleName); + Assert.DoesNotContain(symbols, s => s.Kind == "import" && s.Name.StartsWith("src/", StringComparison.Ordinal)); + Assert.Contains("Skipped TypeScript path alias resolution", stderr, StringComparison.Ordinal); + Assert.DoesNotContain(longModuleName, stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Extract_TypeScript_OverlongPathAliasSubstitutionSkipsCandidateWithBoundedWarning() + { + var projectRoot = TestProjectHelper.CreateTempProject("tsconfig_alias_long_substitution_symbols"); + try + { + var maxTargetLength = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasTargetLength"); + var maxSubstitutedTargetLength = GetSymbolExtractorIntConstant("MaxTypeScriptPathAliasSubstitutedTargetLength"); + var wildcard = new string('a', (maxSubstitutedTargetLength / maxTargetLength) + 2); + var longSubstitutingTarget = new string('*', maxTargetLength); + WriteFile( + projectRoot, + "tsconfig.json", + "{\"compilerOptions\":{\"baseUrl\":\".\",\"paths\":{\"@/*\":[\"" + + longSubstitutingTarget + + "\",\"src/*\"]}}}"); + WriteFile(projectRoot, "src/" + wildcard + ".ts", "export const value = 1;\n"); + var sourcePath = WriteFile(projectRoot, "src/main.ts", "import { value } from \"@/" + wildcard + "\";\n"); + + List symbols = []; + var stderr = ConsoleCapture.CaptureError(() => + symbols = SymbolExtractor.Extract(1, "typescript", File.ReadAllText(sourcePath), sourcePath)); + + Assert.Contains(symbols, s => s.Kind == "import" && s.Name == "src/" + wildcard + ".ts"); + Assert.Contains("Skipped TypeScript path alias target substitution", stderr, StringComparison.Ordinal); + Assert.DoesNotContain(longSubstitutingTarget, stderr, StringComparison.Ordinal); + Assert.DoesNotContain(wildcard, stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Extract_JavaScript_ResolvesJsconfigPathAliasImportsAndKeepsMissesLiteral() { @@ -18226,6 +18407,13 @@ public void Extract_JavaScript_ResolvesJsconfigPathAliasImportsAndKeepsMissesLit } } + private static int GetSymbolExtractorIntConstant(string name) + { + var field = typeof(SymbolExtractor).GetField(name, BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(field); + return Assert.IsType(field.GetRawConstantValue()); + } + private static bool IsSymbolRegexOwnerType(Type type) => type.Namespace == "CodeIndex.Indexer" && (type.Name == "SymbolExtractor" || type.Name.EndsWith("SymbolNameNormalizer", StringComparison.Ordinal));