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/3069.security.md
Original file line number Diff line number Diff line change
@@ -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 を出すようにしました。
110 changes: 104 additions & 6 deletions src/CodeIndex/Indexer/Symbols/SymbolExtractor.TypeScriptPathAliases.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> TypeScriptPathAliasReportedWarnings = new(StringComparer.Ordinal);
private static readonly JsonDocumentOptions TypeScriptPathAliasConfigJsonOptions = new()
Expand All @@ -18,7 +25,7 @@ public static partial class SymbolExtractor
MaxDepth = MaxTypeScriptPathAliasConfigJsonDepth,
};

private sealed record TypeScriptPathAliasConfig(string ProjectDirectory, string BaseDirectory, bool HasBaseUrl, IReadOnlyList<TypeScriptPathAliasRule> Rules);
private sealed record TypeScriptPathAliasConfig(string ConfigPath, string ProjectDirectory, string BaseDirectory, bool HasBaseUrl, IReadOnlyList<TypeScriptPathAliasRule> Rules);

private sealed record TypeScriptPathAliasRule(string Pattern, string BaseDirectory, IReadOnlyList<string> Targets);

Expand All @@ -40,16 +47,27 @@ 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))
continue;

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);
Expand Down Expand Up @@ -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<string>();
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));
}
}

Expand Down Expand Up @@ -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))
Expand Down
188 changes: 188 additions & 0 deletions tests/CodeIndex.Tests/SymbolExtractorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SymbolRecord> 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<SymbolRecord> 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<SymbolRecord> 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<SymbolRecord> 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<SymbolRecord> 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()
{
Expand Down Expand Up @@ -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<int>(field.GetRawConstantValue());
}

private static bool IsSymbolRegexOwnerType(Type type) =>
type.Namespace == "CodeIndex.Indexer"
&& (type.Name == "SymbolExtractor" || type.Name.EndsWith("SymbolNameNormalizer", StringComparison.Ordinal));
Expand Down
Loading