diff --git a/doc/new.md b/doc/new.md index e0471c2a..d2fbd9bc 100644 --- a/doc/new.md +++ b/doc/new.md @@ -27,6 +27,7 @@ The following arguments are available: | Argument | Description | |--------------|-------------| | **-o,--out** | The output directory where the newly created manifests will be saved locally | +| **--allow-unsecure-downloads** | Allow unsecure downloads (HTTP) for this operation. | | **-f,--format** | Output format of the manifest. Default is "yaml". | | **-t,--token** | GitHub personal access token used for direct submission to the Windows Package Manager repo.
⚠️ _Using this argument may result in the token being logged. Consider an alternative approach https://aka.ms/winget-create-token._ | | **-?, --help** | Gets additional help on this command | diff --git a/doc/update.md b/doc/update.md index 513647d2..04f1f97c 100644 --- a/doc/update.md +++ b/doc/update.md @@ -119,6 +119,7 @@ The following arguments are available: | **-r, --replace** | Boolean value for replacing an existing manifest from the Windows Package Manager repo. Optionally provide a version or else the latest version will be replaced. Default is false. | | **-i, --interactive** | Boolean value for making the update command interactive. If true, the tool will prompt the user for input. Default is false. | | **-f,--format** | Output format of the manifest. Default is "yaml". | +| **--allow-unsecure-downloads** | Allow unsecure downloads (HTTP) for this operation. | | **-t, --token** | GitHub personal access token used for direct submission to the Windows Package Manager repo. If no token is provided, tool will prompt for GitHub login credentials.
⚠️ _Using this argument may result in the token being logged. Consider an alternative approach https://aka.ms/winget-create-token._ | | **-?, --help** | Gets additional help on this command. | diff --git a/src/WingetCreateCLI/Commands/BaseCommand.cs b/src/WingetCreateCLI/Commands/BaseCommand.cs index 42228493..d1891bf4 100644 --- a/src/WingetCreateCLI/Commands/BaseCommand.cs +++ b/src/WingetCreateCLI/Commands/BaseCommand.cs @@ -308,9 +308,10 @@ protected static async Task GitHubOAuthLoginFlow() /// /// Downloads the package file from the provided installer url. /// - /// /// Installer Url to be downloaded. + /// Installer Url to be downloaded. + /// The flag indicating whether to allow HTTP downloads. /// Package file. - protected static async Task DownloadPackageFile(string installerUrl) + protected static async Task DownloadPackageFile(string installerUrl, bool allowHttp) { Logger.InfoLocalized(nameof(Resources.DownloadInstaller_Message), installerUrl); @@ -332,7 +333,7 @@ protected static async Task DownloadPackageFile(string installerUrl) try { - string packageFilePath = await PackageParser.DownloadFileAsync(installerUrl); + string packageFilePath = await PackageParser.DownloadFileAsync(installerUrl, allowHttp); TelemetryManager.Log.WriteEvent(new DownloadInstallerEvent { IsSuccessful = true }); DownloadedInstallers.Add(installerUrl, packageFilePath); return packageFilePath; @@ -377,6 +378,16 @@ protected static async Task DownloadPackageFile(string installerUrl) Logger.ErrorLocalized(nameof(Resources.DownloadConnectionTimeout_Error)); return null; } + else if (e is NotSupportedException) + { + Logger.ErrorLocalized(nameof(Resources.DownloadProtocolNotSupported_Error)); + return null; + } + else if (e is DownloadHttpsOnlyException) + { + Logger.ErrorLocalized(nameof(Resources.DownloadHttpsOnly_Error)); + return null; + } else { throw; diff --git a/src/WingetCreateCLI/Commands/NewCommand.cs b/src/WingetCreateCLI/Commands/NewCommand.cs index a696cbdf..6f82369f 100644 --- a/src/WingetCreateCLI/Commands/NewCommand.cs +++ b/src/WingetCreateCLI/Commands/NewCommand.cs @@ -74,6 +74,12 @@ public static IEnumerable Examples [Option('o', "out", Required = false, HelpText = "OutputDirectory_HelpText", ResourceType = typeof(Resources))] public string OutputDir { get; set; } + /// + /// Gets or sets a value indicating whether to allow unsecure downloads. + /// + [Option("allow-unsecure-downloads", Required = false, HelpText = "AllowUnsecureDownloads_HelpText", ResourceType = typeof(Resources))] + public bool AllowUnsecureDownloads { get; set; } + /// /// Gets or sets the format of the output manifest files. /// @@ -116,7 +122,7 @@ public override async Task Execute() foreach (var installerUrl in this.InstallerUrls) { - string packageFile = await DownloadPackageFile(installerUrl); + string packageFile = await DownloadPackageFile(installerUrl, this.AllowUnsecureDownloads); if (string.IsNullOrEmpty(packageFile)) { return false; diff --git a/src/WingetCreateCLI/Commands/UpdateCommand.cs b/src/WingetCreateCLI/Commands/UpdateCommand.cs index ae0dfab2..c0aeb5ab 100644 --- a/src/WingetCreateCLI/Commands/UpdateCommand.cs +++ b/src/WingetCreateCLI/Commands/UpdateCommand.cs @@ -121,6 +121,12 @@ public static IEnumerable Examples [Option('f', "format", Required = false, HelpText = "ManifestFormat_HelpText", ResourceType = typeof(Resources))] public override ManifestFormat Format { get => base.Format; set => base.Format = value; } + /// + /// Gets or sets a value indicating whether to allow unsecure downloads. + /// + [Option("allow-unsecure-downloads", Required = false, HelpText = "AllowUnsecureDownloads_HelpText", ResourceType = typeof(Resources))] + public bool AllowUnsecureDownloads { get; set; } + /// /// Gets or sets the GitHub token used to submit a pull request on behalf of the user. /// @@ -406,7 +412,7 @@ public async Task UpdateManifestsAutonomously(Manifests manifests) foreach (var installerUpdate in installerMetadataList) { - string packageFile = await DownloadPackageFile(installerUpdate.InstallerUrl); + string packageFile = await DownloadPackageFile(installerUpdate.InstallerUrl, this.AllowUnsecureDownloads); if (string.IsNullOrEmpty(packageFile)) { return null; @@ -1005,7 +1011,7 @@ private async Task UpdateSingleInstallerInteractively(Installer installer) { string url = Prompt.Input(Resources.NewInstallerUrl_Message, null, null, new[] { FieldValidation.ValidateProperty(newInstaller, nameof(Installer.InstallerUrl)) }); - string packageFile = await DownloadPackageFile(url); + string packageFile = await DownloadPackageFile(url, this.AllowUnsecureDownloads); string archivePath = null; if (string.IsNullOrEmpty(packageFile)) diff --git a/src/WingetCreateCLI/Properties/Resources.Designer.cs b/src/WingetCreateCLI/Properties/Resources.Designer.cs index 5d40723a..d2b0f9e0 100644 --- a/src/WingetCreateCLI/Properties/Resources.Designer.cs +++ b/src/WingetCreateCLI/Properties/Resources.Designer.cs @@ -168,6 +168,15 @@ public static string AllowedMarkets_KeywordDescription { } } + /// + /// Looks up a localized string similar to Allow unsecure downloads (HTTP) for this operation.. + /// + public static string AllowUnsecureDownloads_HelpText { + get { + return ResourceManager.GetString("AllowUnsecureDownloads_HelpText", resourceCulture); + } + } + /// /// Looks up a localized string similar to The manifest creation command line utility generates manifest for submitting apps to the Windows Package Manager repo.. /// @@ -744,6 +753,15 @@ public static string DownloadFileExceedsMaxSize_Error { } } + /// + /// Looks up a localized string similar to Only HTTPS URLs are supported without "--allow-unsecure-downloads".. + /// + public static string DownloadHttpsOnly_Error { + get { + return ResourceManager.GetString("DownloadHttpsOnly_Error", resourceCulture); + } + } + /// /// Looks up a localized string similar to Downloading and parsing: {0}.... /// @@ -753,6 +771,15 @@ public static string DownloadInstaller_Message { } } + /// + /// Looks up a localized string similar to Only HTTPS URLs are supported for downloads. Use the "--allow-unsecure-downloads" option to allow HTTP URLs.. + /// + public static string DownloadProtocolNotSupported_Error { + get { + return ResourceManager.GetString("DownloadProtocolNotSupported_Error", resourceCulture); + } + } + /// /// Looks up a localized string similar to DSC v3 resource commands. /// @@ -862,7 +889,7 @@ public static string DscResourcePropertyDescriptionSettings { } /// - /// Looks up a localized string similar to Execute the Schema command. + /// Looks up a localized string similar to Outputs schema of the resource. /// public static string DscSchema_HelpText { get { diff --git a/src/WingetCreateCLI/Properties/Resources.resx b/src/WingetCreateCLI/Properties/Resources.resx index 9f58f851..e5791b33 100644 --- a/src/WingetCreateCLI/Properties/Resources.resx +++ b/src/WingetCreateCLI/Properties/Resources.resx @@ -1471,4 +1471,13 @@ Warning: Using this argument may result in the token being logged. Consider an a The scope value for Microsoft Entra Id authentication + + Only HTTPS URLs are supported for downloads. Use the "--allow-unsecure-downloads" option to allow HTTP URLs. + + + Only HTTPS URLs are supported without "--allow-unsecure-downloads". + + + Allow unsecure downloads (HTTP) for this operation. + \ No newline at end of file diff --git a/src/WingetCreateCore/Common/Exceptions/DownloadHttpsOnlyException.cs b/src/WingetCreateCore/Common/Exceptions/DownloadHttpsOnlyException.cs new file mode 100644 index 00000000..1c9c6459 --- /dev/null +++ b/src/WingetCreateCore/Common/Exceptions/DownloadHttpsOnlyException.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. + +namespace Microsoft.WingetCreateCore.Common.Exceptions +{ + using System; + + /// + /// The exception that is thrown when the download URL is not HTTPS. + /// + public class DownloadHttpsOnlyException : Exception + { + } +} diff --git a/src/WingetCreateCore/Common/PackageParser.cs b/src/WingetCreateCore/Common/PackageParser.cs index 5c24142c..984650c2 100644 --- a/src/WingetCreateCore/Common/PackageParser.cs +++ b/src/WingetCreateCore/Common/PackageParser.cs @@ -117,16 +117,19 @@ public static void ParsePackages(List installerMetadataList, /// Download file at specified URL to temp directory, unless it's already present. /// /// The URL of the file to be downloaded. + /// Whether to allow HTTP downloads. /// The maximum file size in bytes to download. /// Path of downloaded, or previously downloaded, file. - public static async Task DownloadFileAsync(string url, long? maxDownloadSize = null) + public static async Task DownloadFileAsync(string url, bool allowHttp, long? maxDownloadSize = null) { + ValidateUrl(url, allowHttp); var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); int redirectCount = 0; while (response.StatusCode == System.Net.HttpStatusCode.Redirect && redirectCount < 2) { var redirectUri = response.Headers.Location; + ValidateUrl(redirectUri, allowHttp); response = await httpClient.GetAsync(redirectUri, HttpCompletionOption.ResponseHeadersRead); redirectCount++; } @@ -1116,5 +1119,28 @@ private static string RemoveInvalidCharsFromString(string value) { return Regex.Replace(value, InvalidCharacters, string.Empty); } + + private static void ValidateUrl(string url, bool allowHttp) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri downloadUrl)) + { + throw new InvalidOperationException(); + } + + ValidateUrl(downloadUrl, allowHttp); + } + + private static void ValidateUrl(Uri url, bool allowHttp) + { + if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps) + { + throw new NotSupportedException(); + } + + if (!allowHttp && url.Scheme != Uri.UriSchemeHttps) + { + throw new DownloadHttpsOnlyException(); + } + } } } diff --git a/src/WingetCreateTests/WingetCreateTests/TestUtils.cs b/src/WingetCreateTests/WingetCreateTests/TestUtils.cs index 5a97585b..99f5e916 100644 --- a/src/WingetCreateTests/WingetCreateTests/TestUtils.cs +++ b/src/WingetCreateTests/WingetCreateTests/TestUtils.cs @@ -139,7 +139,7 @@ public static string MockDownloadFile(string filename) { string url = $"https://fakedomain.com/{filename}"; SetMockHttpResponseContent(filename); - string downloadedPath = PackageParser.DownloadFileAsync(url).Result; + string downloadedPath = PackageParser.DownloadFileAsync(url, false).Result; return downloadedPath; } diff --git a/src/WingetCreateTests/WingetCreateTests/UnitTests/NewCommandTests.cs b/src/WingetCreateTests/WingetCreateTests/UnitTests/NewCommandTests.cs index 0fa9c109..97a7d278 100644 --- a/src/WingetCreateTests/WingetCreateTests/UnitTests/NewCommandTests.cs +++ b/src/WingetCreateTests/WingetCreateTests/UnitTests/NewCommandTests.cs @@ -16,7 +16,16 @@ namespace Microsoft.WingetCreateUnitTests /// Test cases for verifying that the "new" command is working as expected. /// public class NewCommandTests - { + { + /// + /// OneTimeSetup method for the New command unit tests. + /// + [OneTimeSetUp] + public void OneTimeSetUp() + { + Logger.Initialize(); + } + /// /// Verifies that the CLI errors out on an invalid installer URL. /// @@ -27,11 +36,44 @@ public async Task InvalidUrl() using StringWriter sw = new StringWriter(); Console.SetOut(sw); - Logger.Initialize(); NewCommand command = new NewCommand { InstallerUrls = new[] { "invalidUrl" } }; ClassicAssert.IsFalse(await command.Execute(), "Command should have failed"); string actual = sw.ToString(); Assert.That(actual, Does.Contain(Resources.DownloadFile_Error), "Failed to catch invalid URL"); } + + /// + /// Verifies that the CLI errors out on an invalid protocol. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task InvalidProtocol() + { + using StringWriter sw = new StringWriter(); + Console.SetOut(sw); + + NewCommand command = new NewCommand { InstallerUrls = new[] { "ftp://mock" }, AllowUnsecureDownloads = true }; + ClassicAssert.IsFalse(await command.Execute(), "Command should have failed"); + string actual = sw.ToString(); + Assert.That(actual, Does.Contain(Resources.DownloadProtocolNotSupported_Error)); + Assert.That(command.AllowUnsecureDownloads, Is.True); + } + + /// + /// Tests that the command execution fails when using non-HTTPS URLs. + /// + /// A representing the asynchronous unit test. + [Test] + public async Task HttpsOnly() + { + using StringWriter sw = new StringWriter(); + Console.SetOut(sw); + + NewCommand command = new NewCommand { InstallerUrls = new[] { "http://mock" } }; + ClassicAssert.IsFalse(await command.Execute(), "Command should have failed"); + string actual = sw.ToString(); + Assert.That(actual, Does.Contain(Resources.DownloadHttpsOnly_Error)); + Assert.That(command.AllowUnsecureDownloads, Is.False); + } } }