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
90 changes: 88 additions & 2 deletions src/NetCoreToolService/Controllers/NewController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,17 @@ public sealed partial class NewController : ControllerBase
private static readonly AsyncLock WriteLock = new();
private static readonly SearchValues<char> LineBreakValues = SearchValues.Create('\r', '\n');

// Options that must never be passed to `dotnet new`, regardless of source.
private static readonly HashSet<string> 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<NewController> _logger;
Expand Down Expand Up @@ -98,6 +110,11 @@ public async Task<ActionResult> 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();
Expand Down Expand Up @@ -149,6 +166,11 @@ public async Task<ActionResult> 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);
Expand All @@ -174,6 +196,11 @@ public async Task<ActionResult> UninstallTemplates(string nuGetId)
return Ok(oldTemplates);
}

private static bool IsNuGetPackageIdValid(string nuGetId)
{
return NuGetPackageIdRegex().IsMatch(nuGetId);
}

/// <summary>
/// Returns "help" for the specified project template.
/// </summary>
Expand All @@ -188,6 +215,11 @@ public async Task<ActionResult> 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<char> outputSpan = helpCommand.Output.AsSpan();

Expand Down Expand Up @@ -228,6 +260,11 @@ public async Task<ActionResult> 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}'.");
Expand All @@ -238,7 +275,13 @@ public async Task<ActionResult> GetTemplateProject(string template, string? opti

try
{
List<string> optionList = ParseOptions(options, out string outputName);
List<string> 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}");

Expand All @@ -257,9 +300,15 @@ public async Task<ActionResult> GetTemplateProject(string template, string? opti
}
}

private static List<string> ParseOptions(string? options, out string outputName)
private static bool IsTemplateShortNameValid(string template)
{
return TemplateShortNameRegex().IsMatch(template);
}

private static List<string> ParseOptions(string? options, out string outputName, out string? invalidOptionName)
{
outputName = DefaultOutputName;
invalidOptionName = null;
var optionList = new List<string>();

if (options is not null)
Expand Down Expand Up @@ -294,13 +343,25 @@ private static List<string> ParseOptions(string? options, out string outputName)
}
else
{
if (!IsValidOptionName(keySpan))
{
invalidOptionName = keySpan.ToString();
return [];
}

ReadOnlySpan<char> escapedValueSpan = valueSpan.Contains(' ') ? $"\"{valueSpan}\"" : valueSpan;
optionList.Add($"--{keySpan}={escapedValueSpan}");
}
}
}
else
{
if (!IsValidOptionName(optionSpan))
{
invalidOptionName = optionSpan.ToString();
return [];
}

optionList.Add($"--{optionSpan}");
}
}
Expand All @@ -311,6 +372,24 @@ private static List<string> ParseOptions(string? options, out string outputName)
return optionList;
}

private static bool IsValidOptionName(ReadOnlySpan<char> 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<ActionResult> ExecuteCreateProjectCommandAsync(string template, string commandLine, TempDirectory projectDirectory, IPackager packager,
string outputName)
{
Expand Down Expand Up @@ -439,6 +518,13 @@ private async Task<TemplateDictionary> 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)
Expand Down
94 changes: 94 additions & 0 deletions test/NetCoreToolService.Test/Controllers/NewControllerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandExecutor>.Instance);
var controller = new NewController(executor, EmptyConfiguration, NullLogger<NewController>.Instance);

// Act
ActionResult result = await controller.GetTemplateProject(template);

// Assert
BadRequestObjectResult badRequest = result.Should().BeOfType<BadRequestObjectResult>().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<CommandExecutor>.Instance);
var controller = new NewController(executor, EmptyConfiguration, NullLogger<NewController>.Instance);

// Act
ActionResult result = await controller.GetTemplateHelp(template);

// Assert
BadRequestObjectResult badRequest = result.Should().BeOfType<BadRequestObjectResult>().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<CommandExecutor>.Instance);
var controller = new NewController(executor, EmptyConfiguration, NullLogger<NewController>.Instance);

// Act
ActionResult result = await controller.GetTemplateProject("classlib", options);

// Assert
result.Should().BeOfType<BadRequestObjectResult>();
}

[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<CommandExecutor>.Instance);
var controller = new NewController(executor, SensitiveEndpointsEnabledConfiguration, NullLogger<NewController>.Instance);

// Act
ActionResult result = await controller.InstallTemplates(nuGetId);

// Assert
BadRequestObjectResult badRequest = result.Should().BeOfType<BadRequestObjectResult>().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<CommandExecutor>.Instance);
var controller = new NewController(executor, SensitiveEndpointsEnabledConfiguration, NullLogger<NewController>.Instance);

// Act
ActionResult result = await controller.UninstallTemplates(nuGetId);

// Assert
BadRequestObjectResult badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().Be($"Invalid NuGet package ID '{nuGetId}'.");
}
}
Loading