diff --git a/src/NetCoreToolService/Controllers/NewController.cs b/src/NetCoreToolService/Controllers/NewController.cs index c8553f4..9d27d71 100644 --- a/src/NetCoreToolService/Controllers/NewController.cs +++ b/src/NetCoreToolService/Controllers/NewController.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Net; using System.Text; +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; using Steeltoe.NetCoreToolService.Models; using Steeltoe.NetCoreToolService.Packagers; @@ -34,6 +35,17 @@ public sealed partial class NewController : ControllerBase private static readonly AsyncLock WriteLock = new(); private static readonly SearchValues LineBreakValues = SearchValues.Create('\r', '\n'); + // Options that must never be passed to `dotnet new`, regardless of source. + private static readonly HashSet BlockedOptionNames = new([ + "allow-scripts", + "add-source", + "force", + "dry-run", + "no-update-check", + "interactive", + "project" + ], StringComparer.OrdinalIgnoreCase); + private readonly ICommandExecutor _commandExecutor; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -98,6 +110,11 @@ public async Task InstallTemplates(string nuGetId) return StatusCode((int)HttpStatusCode.ServiceUnavailable, SensitiveEndpointUnavailableMessage); } + if (!IsNuGetPackageIdValid(nuGetId)) + { + return BadRequest($"Invalid NuGet package ID '{nuGetId}'."); + } + using IDisposable lockScope = await WriteLock.AcquireAsync(); await _commandExecutor.ExecuteAsync($"{ExecutableName} new uninstall {nuGetId}", null, -1); TemplateDictionary oldTemplates = await GetTemplateDictionaryAsync(); @@ -149,6 +166,11 @@ public async Task UninstallTemplates(string nuGetId) return StatusCode((int)HttpStatusCode.ServiceUnavailable, SensitiveEndpointUnavailableMessage); } + if (!IsNuGetPackageIdValid(nuGetId)) + { + return BadRequest($"Invalid NuGet package ID '{nuGetId}'."); + } + using IDisposable lockScope = await WriteLock.AcquireAsync(); TemplateDictionary oldTemplates = await GetTemplateDictionaryAsync(); CommandResult uninstallCommand = await _commandExecutor.ExecuteAsync($"{ExecutableName} new uninstall {nuGetId}", null, -1); @@ -174,6 +196,11 @@ public async Task UninstallTemplates(string nuGetId) return Ok(oldTemplates); } + private static bool IsNuGetPackageIdValid(string nuGetId) + { + return NuGetPackageIdRegex().IsMatch(nuGetId); + } + /// /// Returns "help" for the specified project template. /// @@ -188,6 +215,11 @@ public async Task GetTemplateHelp(string template) { ArgumentNullException.ThrowIfNull(template); + if (!IsTemplateShortNameValid(template)) + { + return BadRequest($"Invalid template name '{template}'."); + } + CommandResult helpCommand = await _commandExecutor.ExecuteAsync($"{ExecutableName} new {template} --help", null, -1); ReadOnlySpan outputSpan = helpCommand.Output.AsSpan(); @@ -228,6 +260,11 @@ public async Task GetTemplateProject(string template, string? opti { ArgumentNullException.ThrowIfNull(template); + if (!IsTemplateShortNameValid(template)) + { + return BadRequest($"Invalid template name '{template}'."); + } + if (!_packagers.TryGetValue(packaging, out IPackager? packager)) { return BadRequest($"Unknown or unsupported packaging format '{packaging}'."); @@ -238,7 +275,13 @@ public async Task GetTemplateProject(string template, string? opti try { - List optionList = ParseOptions(options, out string outputName); + List optionList = ParseOptions(options, out string outputName, out string? invalidOptionName); + + if (invalidOptionName is not null) + { + return BadRequest($"Invalid or blocked option name '{invalidOptionName}'."); + } + using var projectDirectory = new TempDirectory("NetCoreToolService-"); var commandLineBuilder = new StringBuilder($"{ExecutableName} new {template}"); @@ -257,9 +300,15 @@ public async Task GetTemplateProject(string template, string? opti } } - private static List ParseOptions(string? options, out string outputName) + private static bool IsTemplateShortNameValid(string template) + { + return TemplateShortNameRegex().IsMatch(template); + } + + private static List ParseOptions(string? options, out string outputName, out string? invalidOptionName) { outputName = DefaultOutputName; + invalidOptionName = null; var optionList = new List(); if (options is not null) @@ -294,6 +343,12 @@ private static List ParseOptions(string? options, out string outputName) } else { + if (!IsValidOptionName(keySpan)) + { + invalidOptionName = keySpan.ToString(); + return []; + } + ReadOnlySpan escapedValueSpan = valueSpan.Contains(' ') ? $"\"{valueSpan}\"" : valueSpan; optionList.Add($"--{keySpan}={escapedValueSpan}"); } @@ -301,6 +356,12 @@ private static List ParseOptions(string? options, out string outputName) } else { + if (!IsValidOptionName(optionSpan)) + { + invalidOptionName = optionSpan.ToString(); + return []; + } + optionList.Add($"--{optionSpan}"); } } @@ -311,6 +372,24 @@ private static List ParseOptions(string? options, out string outputName) return optionList; } + private static bool IsValidOptionName(ReadOnlySpan name) + { + if (name.IsEmpty || !char.IsAsciiLetter(name[0])) + { + return false; + } + + foreach (char ch in name) + { + if (!char.IsAsciiLetterOrDigit(ch) && ch != '-') + { + return false; + } + } + + return !BlockedOptionNames.Contains(name.ToString()); + } + private async Task ExecuteCreateProjectCommandAsync(string template, string commandLine, TempDirectory projectDirectory, IPackager packager, string outputName) { @@ -439,6 +518,13 @@ private async Task GetTemplateDictionaryAsync() [LoggerMessage(LogLevel.Debug, "Generated project in {Elapsed:c}")] partial void LogProjectGenerated(TimeSpan elapsed); + [GeneratedRegex(@"^\.?[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$", RegexOptions.Compiled)] + private static partial Regex TemplateShortNameRegex(); + + // NuGet package IDs: see https://learn.microsoft.com/en-us/nuget/nuget-org/publish-a-package#package-name-limits + [GeneratedRegex(@"^[A-Za-z0-9_][A-Za-z0-9\.\-]*$", RegexOptions.Compiled)] + private static partial Regex NuGetPackageIdRegex(); + private readonly record struct ListTable(Range TemplateNameRange, Range ShortNameRange, Range LanguageRange, Range TagsRange) { public Range GetTagsRangeForLine(int lineLength) diff --git a/test/NetCoreToolService.Test/Controllers/NewControllerTest.cs b/test/NetCoreToolService.Test/Controllers/NewControllerTest.cs index 31b731f..53c061a 100644 --- a/test/NetCoreToolService.Test/Controllers/NewControllerTest.cs +++ b/test/NetCoreToolService.Test/Controllers/NewControllerTest.cs @@ -509,4 +509,98 @@ public async Task GetTemplateProject_UnknownOutput_Should_Return_InternalServerE errorResult.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); errorResult.Value.Should().Be("Unexpected output."); } + + [Theory] + [InlineData("install evil-pkg")] + [InlineData("--allow-scripts")] + [InlineData("new install")] + [InlineData("../evil")] + [InlineData("evil;id")] + public async Task GetTemplateProject_InjectedTemplateName_Should_Return_BadRequest(string template) + { + // Arrange + var executor = new CommandExecutor(NullLogger.Instance); + var controller = new NewController(executor, EmptyConfiguration, NullLogger.Instance); + + // Act + ActionResult result = await controller.GetTemplateProject(template); + + // Assert + BadRequestObjectResult badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().Be($"Invalid template name '{template}'."); + } + + [Theory] + [InlineData("install evil-pkg")] + [InlineData("--allow-scripts")] + [InlineData("../evil")] + public async Task GetTemplateHelp_InjectedTemplateName_Should_Return_BadRequest(string template) + { + // Arrange + var executor = new CommandExecutor(NullLogger.Instance); + var controller = new NewController(executor, EmptyConfiguration, NullLogger.Instance); + + // Act + ActionResult result = await controller.GetTemplateHelp(template); + + // Assert + BadRequestObjectResult badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().Be($"Invalid template name '{template}'."); + } + + [Theory] + [InlineData("allow-scripts=yes")] + [InlineData("allow-scripts")] + [InlineData("--allow-scripts=yes")] + [InlineData("force=true")] + [InlineData("name=foo,allow-scripts=yes")] + public async Task GetTemplateProject_BlockedOrInvalidOption_Should_Return_BadRequest(string options) + { + // Arrange + var executor = new CommandExecutor(NullLogger.Instance); + var controller = new NewController(executor, EmptyConfiguration, NullLogger.Instance); + + // Act + ActionResult result = await controller.GetTemplateProject("classlib", options); + + // Assert + result.Should().BeOfType(); + } + + [Theory] + [InlineData("evil pkg")] + [InlineData("evil;pkg")] + [InlineData("../Evil.Templates")] + [InlineData("evil|pkg")] + public async Task InstallTemplates_InvalidNuGetId_Should_Return_BadRequest(string nuGetId) + { + // Arrange + var executor = new CommandExecutor(NullLogger.Instance); + var controller = new NewController(executor, SensitiveEndpointsEnabledConfiguration, NullLogger.Instance); + + // Act + ActionResult result = await controller.InstallTemplates(nuGetId); + + // Assert + BadRequestObjectResult badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().Be($"Invalid NuGet package ID '{nuGetId}'."); + } + + [Theory] + [InlineData("evil pkg")] + [InlineData("evil;pkg")] + [InlineData("../Evil.Templates")] + public async Task UninstallTemplates_InvalidNuGetId_Should_Return_BadRequest(string nuGetId) + { + // Arrange + var executor = new CommandExecutor(NullLogger.Instance); + var controller = new NewController(executor, SensitiveEndpointsEnabledConfiguration, NullLogger.Instance); + + // Act + ActionResult result = await controller.UninstallTemplates(nuGetId); + + // Assert + BadRequestObjectResult badRequest = result.Should().BeOfType().Subject; + badRequest.Value.Should().Be($"Invalid NuGet package ID '{nuGetId}'."); + } }