diff --git a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSourcesService.cs b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSourcesService.cs index e796799b0f5..9134c1c453a 100644 --- a/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSourcesService.cs +++ b/src/NuGet.Clients/NuGet.PackageManagement.VisualStudio/Services/NuGetSourcesService.cs @@ -94,6 +94,7 @@ private IReadOnlyList GetPackageSourcesToUpdate(IReadOnlyList GetPackageSourcesToUpdate(IReadOnlyList public const int MaxProtocolVersion = 3; internal const bool DefaultAllowInsecureConnections = false; + internal const bool DefaultDisableTLSCertificateValidation = false; private int _hashCode; private string _source; @@ -107,6 +108,11 @@ public string Source /// public bool AllowInsecureConnections { get; set; } = DefaultAllowInsecureConnections; + /// + /// Gets or sets disableTLSCertificateValidation of the source. Defaults to false. + /// + public bool DisableTLSCertificateValidation { get; set; } = DefaultDisableTLSCertificateValidation; + /// /// Whether the source is using the HTTP protocol, including HTTPS. /// @@ -160,11 +166,16 @@ public SourceItem AsSourceItem() } string? allowInsecureConnections = null; + string? disableTLSCertificateValidation = null; if (AllowInsecureConnections != DefaultAllowInsecureConnections) { allowInsecureConnections = $"{AllowInsecureConnections}"; } - return new SourceItem(Name, Source, protocolVersion, allowInsecureConnections); + if (DisableTLSCertificateValidation != DefaultDisableTLSCertificateValidation) + { + disableTLSCertificateValidation = $"{DisableTLSCertificateValidation}"; + } + return new SourceItem(Name, Source, protocolVersion, allowInsecureConnections, disableTLSCertificateValidation); } public bool Equals(PackageSource? other) @@ -202,6 +213,7 @@ public PackageSource Clone() IsMachineWide = IsMachineWide, ProtocolVersion = ProtocolVersion, AllowInsecureConnections = AllowInsecureConnections, + DisableTLSCertificateValidation = DisableTLSCertificateValidation, }; } } diff --git a/src/NuGet.Core/NuGet.Configuration/PackageSource/PackageSourceProvider.cs b/src/NuGet.Core/NuGet.Configuration/PackageSource/PackageSourceProvider.cs index 42993eeec5a..55c5d21cc12 100644 --- a/src/NuGet.Core/NuGet.Configuration/PackageSource/PackageSourceProvider.cs +++ b/src/NuGet.Core/NuGet.Configuration/PackageSource/PackageSourceProvider.cs @@ -244,6 +244,7 @@ internal static PackageSource ReadPackageSource(SourceItem setting, bool isEnabl packageSource.ProtocolVersion = ReadProtocolVersion(setting); packageSource.AllowInsecureConnections = ReadAllowInsecureConnections(setting); + packageSource.DisableTLSCertificateValidation = ReadDisableTLSCertificateValidation(setting); return packageSource; } @@ -258,6 +259,16 @@ private static int ReadProtocolVersion(SourceItem setting) return PackageSource.DefaultProtocolVersion; } + private static bool ReadDisableTLSCertificateValidation(SourceItem setting) + { + if (bool.TryParse(setting.DisableTLSCertificateValidation, out var disableTLSCertificateValidation)) + { + return disableTLSCertificateValidation; + } + + return PackageSource.DefaultDisableTLSCertificateValidation; + } + private static bool ReadAllowInsecureConnections(SourceItem setting) { if (bool.TryParse(setting.AllowInsecureConnections, out var allowInsecureConnections)) diff --git a/src/NuGet.Core/NuGet.Configuration/PublicAPI.Unshipped.txt b/src/NuGet.Core/NuGet.Configuration/PublicAPI.Unshipped.txt index 7dc5c58110b..76d6be5a765 100644 --- a/src/NuGet.Core/NuGet.Configuration/PublicAPI.Unshipped.txt +++ b/src/NuGet.Core/NuGet.Configuration/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +NuGet.Configuration.PackageSource.DisableTLSCertificateValidation.get -> bool +NuGet.Configuration.PackageSource.DisableTLSCertificateValidation.set -> void +NuGet.Configuration.SourceItem.DisableTLSCertificateValidation.get -> string? +NuGet.Configuration.SourceItem.DisableTLSCertificateValidation.set -> void +NuGet.Configuration.SourceItem.SourceItem(string! key, string! value, string? protocolVersion, string? allowInsecureConnections, string? disableTLSCertificateValidation) -> void +static readonly NuGet.Configuration.ConfigurationConstants.DisableTLSCertificateValidation -> string! diff --git a/src/NuGet.Core/NuGet.Configuration/Settings/Items/SourceItem.cs b/src/NuGet.Core/NuGet.Configuration/Settings/Items/SourceItem.cs index 44411ad5e4e..a6b99013710 100644 --- a/src/NuGet.Core/NuGet.Configuration/Settings/Items/SourceItem.cs +++ b/src/NuGet.Core/NuGet.Configuration/Settings/Items/SourceItem.cs @@ -35,17 +35,36 @@ public string? AllowInsecureConnections set => AddOrUpdateAttribute(ConfigurationConstants.AllowInsecureConnections, value); } + public string? DisableTLSCertificateValidation + { + get + { + if (Attributes.TryGetValue(ConfigurationConstants.DisableTLSCertificateValidation, out var attribute)) + { + return Settings.ApplyEnvironmentTransform(attribute); + } + + return null; + } + set => AddOrUpdateAttribute(ConfigurationConstants.DisableTLSCertificateValidation, value); + } + public SourceItem(string key, string value) - : this(key, value, protocolVersion: "", allowInsecureConnections: "") + : this(key, value, protocolVersion: "", allowInsecureConnections: "", disableTLSCertificateValidation: "") { } public SourceItem(string key, string value, string? protocolVersion) - : this(key, value, protocolVersion, allowInsecureConnections: "") + : this(key, value, protocolVersion, allowInsecureConnections: "", disableTLSCertificateValidation: "") { } public SourceItem(string key, string value, string? protocolVersion, string? allowInsecureConnections) + : this(key, value, protocolVersion, allowInsecureConnections, disableTLSCertificateValidation: "") + { + } + + public SourceItem(string key, string value, string? protocolVersion, string? allowInsecureConnections, string? disableTLSCertificateValidation) : base(key, value) { if (!string.IsNullOrEmpty(protocolVersion)) @@ -56,6 +75,10 @@ public SourceItem(string key, string value, string? protocolVersion, string? all { AllowInsecureConnections = allowInsecureConnections; } + if (!string.IsNullOrEmpty(disableTLSCertificateValidation)) + { + DisableTLSCertificateValidation = disableTLSCertificateValidation; + } } internal SourceItem(XElement element, SettingsFile origin) @@ -65,7 +88,7 @@ internal SourceItem(XElement element, SettingsFile origin) public override SettingBase Clone() { - var newSetting = new SourceItem(Key, Value, ProtocolVersion, AllowInsecureConnections); + var newSetting = new SourceItem(Key, Value, ProtocolVersion, AllowInsecureConnections, DisableTLSCertificateValidation); if (Origin != null) { diff --git a/src/NuGet.Core/NuGet.Configuration/Utility/ConfigurationContants.cs b/src/NuGet.Core/NuGet.Configuration/Utility/ConfigurationContants.cs index d82b381b523..c2d9c707a1d 100644 --- a/src/NuGet.Core/NuGet.Configuration/Utility/ConfigurationContants.cs +++ b/src/NuGet.Core/NuGet.Configuration/Utility/ConfigurationContants.cs @@ -53,6 +53,8 @@ public static class ConfigurationConstants public static readonly string DisabledPackageSources = "disabledPackageSources"; + public static readonly string DisableTLSCertificateValidation = "disableTLSCertificateValidation"; + public static readonly string DoNotShowPackageManagementSelectionKey = "disabled"; public static readonly string Enabled = "enabled"; diff --git a/src/NuGet.Core/NuGet.Protocol/HttpSource/HttpHandlerResourceV3Provider.cs b/src/NuGet.Core/NuGet.Protocol/HttpSource/HttpHandlerResourceV3Provider.cs index 5fef288fe0e..627c5091ada 100644 --- a/src/NuGet.Core/NuGet.Protocol/HttpSource/HttpHandlerResourceV3Provider.cs +++ b/src/NuGet.Core/NuGet.Protocol/HttpSource/HttpHandlerResourceV3Provider.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using NuGet.Configuration; @@ -17,6 +19,11 @@ public class HttpHandlerResourceV3Provider : ResourceProvider { private readonly IProxyCache _proxyCache; +#if NETSTANDARD2_0 + internal static Func DangerousAcceptAnyServerCertificateValidator = + (message, certificate, chain, policyErrors) => true; +#endif + public HttpHandlerResourceV3Provider() : this(ProxyCache.Instance) { @@ -56,6 +63,18 @@ private HttpHandlerResourceV3 CreateResource(PackageSource packageSource) AutomaticDecompression = (DecompressionMethods.GZip | DecompressionMethods.Deflate), }; +#if NETSTANDARD2_0 + if (packageSource.DisableTLSCertificateValidation) + { + clientHandler.ServerCertificateCustomValidationCallback = DangerousAcceptAnyServerCertificateValidator; + } +#else + if (packageSource.DisableTLSCertificateValidation) + { + clientHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + } +#endif + #if IS_DESKTOP if (packageSource.MaxHttpRequestsPerSource > 0) { diff --git a/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Services/NuGetSourcesServiceTests.cs b/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Services/NuGetSourcesServiceTests.cs index 17022834204..492b1f70395 100644 --- a/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Services/NuGetSourcesServiceTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.PackageManagement.VisualStudio.Test/Services/NuGetSourcesServiceTests.cs @@ -168,5 +168,42 @@ public async Task Save_SourceWithDifferentAllowInsecureConnections_SavesNewValue savedSources[0].ProtocolVersion.Should().Be(3); savedSources[0].AllowInsecureConnections.Should().Be(true); } + + [Fact] + public async Task Save_SourceWithDifferentDisableTLSCertificateVerification_SavesNewValue() + { + PackageSource packageSource = new(name: "Source-Name", source: "Source-Path") + { + ProtocolVersion = 3, + DisableTLSCertificateValidation = false + }; + + Mock packageSourceProvider = new(); + packageSourceProvider.Setup(psp => psp.LoadPackageSources()) + .Returns(new[] { packageSource }); + + List? savedSources = null; + packageSourceProvider.Setup(psp => psp.SavePackageSources(It.IsAny>())) + .Callback((IEnumerable newSources) => { savedSources = newSources.ToList(); }); + + var target = new NuGetSourcesService(options: default, + Mock.Of(), + new AuthorizationServiceClient(Mock.Of()), + packageSourceProvider.Object); + + List updatedSources = new(1) + { + new PackageSourceContextInfo(packageSource.Source, packageSource.Name, packageSource.IsEnabled, protocolVersion: 3, allowInsecureConnections: false, disableTLSCertificateValidation: true) + }; + + // Act + await target.SavePackageSourceContextInfosAsync(updatedSources, CancellationToken.None); + + // Assert + savedSources.Should().NotBeNull(); + savedSources!.Count.Should().Be(1); + savedSources[0].ProtocolVersion.Should().Be(3); + savedSources[0].DisableTLSCertificateValidation.Should().Be(true); + } } } diff --git a/test/NuGet.Clients.Tests/NuGet.VisualStudio.Internal.Contracts.Test/Formatters/PackageSourceContextInfoFormatterTests.cs b/test/NuGet.Clients.Tests/NuGet.VisualStudio.Internal.Contracts.Test/Formatters/PackageSourceContextInfoFormatterTests.cs index b9fd7fda0d9..5d45de43bf3 100644 --- a/test/NuGet.Clients.Tests/NuGet.VisualStudio.Internal.Contracts.Test/Formatters/PackageSourceContextInfoFormatterTests.cs +++ b/test/NuGet.Clients.Tests/NuGet.VisualStudio.Internal.Contracts.Test/Formatters/PackageSourceContextInfoFormatterTests.cs @@ -19,6 +19,7 @@ public void SerializeThenDeserialize_WithValidArguments_RoundTrips(PackageSource public static TheoryData TestData => new TheoryData { + { new PackageSourceContextInfo("source", "name", isEnabled: true, protocolVersion: 3, allowInsecureConnections: true, disableTLSCertificateValidation: true) }, { new PackageSourceContextInfo("source", "name", isEnabled: true, protocolVersion: 3, allowInsecureConnections: true) }, { new PackageSourceContextInfo("source", "name", isEnabled: true, protocolVersion: 3) }, { new PackageSourceContextInfo("source", "name", isEnabled: true) }, diff --git a/test/NuGet.Core.FuncTests/Dotnet.Integration.Test/DotnetRestoreTLSCertificateValidationTests.cs b/test/NuGet.Core.FuncTests/Dotnet.Integration.Test/DotnetRestoreTLSCertificateValidationTests.cs new file mode 100644 index 00000000000..438821e3928 --- /dev/null +++ b/test/NuGet.Core.FuncTests/Dotnet.Integration.Test/DotnetRestoreTLSCertificateValidationTests.cs @@ -0,0 +1,120 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Threading.Tasks; +using NuGet.Packaging; +using NuGet.Test.Utility; +using NuGet.XPlat.FuncTest; +using Test.Utility; +using Xunit; + +namespace Dotnet.Integration.Test +{ + [Collection(DotnetIntegrationCollection.Name)] + public class DotnetRestoreTLSCertificateValidationTests + { + private readonly DotnetIntegrationTestFixture _msbuildFixture; + + public DotnetRestoreTLSCertificateValidationTests(DotnetIntegrationTestFixture fixture) + { + _msbuildFixture = fixture; + } + + [PlatformFact(Platform.Windows)] + public async Task DotnetRestore_withTLSCertificateValidationDisabled_DoesnotThrowException() + { + // Arrange + using var pathContext = _msbuildFixture.CreateSimpleTestPathContext(); + TestDirectory packageSourceDirectory = TestDirectory.Create(); + var packageA100 = new SimpleTestPackageContext("A", "1.0.0"); + await SimpleTestPackageUtility.CreateFolderFeedV3Async( + packageSourceDirectory, + PackageSaveMode.Defaultv3, + packageA100); + var projectA = XPlatTestUtils.CreateProject("ProjectA", pathContext, packageA100, "net472"); + var workingDirectory = Path.Combine(pathContext.SolutionRoot, projectA.ProjectName); + SelfSignedCertificateMockServer tcpListenerServer = new SelfSignedCertificateMockServer(packageSourceDirectory); + var serverTask = tcpListenerServer.StartServerAsync(); + pathContext.Settings.AddSource("https-feed", $"{tcpListenerServer.URI}v3/index.json", "disableTLSCertificateValidation", "true"); + + // Act & Assert + _msbuildFixture.RunDotnetExpectSuccess(workingDirectory, $"restore {projectA.ProjectName}.csproj --configfile {pathContext.Settings.ConfigPath}"); + tcpListenerServer.StopServer(); + } + + [PlatformFact(Platform.Windows)] + public async Task DotnetRestore_withTLSCertificateValidationEnabled_ThrowException() + { + // Arrange + using var pathContext = _msbuildFixture.CreateSimpleTestPathContext(); + TestDirectory packageSourceDirectory = TestDirectory.Create(); + var packageB100 = new SimpleTestPackageContext("myPackg", "1.0.0"); + await SimpleTestPackageUtility.CreateFolderFeedV3Async( + packageSourceDirectory, + PackageSaveMode.Defaultv3, + packageB100); + var projectB = XPlatTestUtils.CreateProject("ProjectB", pathContext, packageB100, "net472"); + var workingDirectory = Path.Combine(pathContext.SolutionRoot, projectB.ProjectName); + SelfSignedCertificateMockServer tcpListenerServer = new SelfSignedCertificateMockServer(packageSourceDirectory); + var serverTask = tcpListenerServer.StartServerAsync(); + pathContext.Settings.AddSource("https-feed", $"{tcpListenerServer.URI}v3/index.json"); + + // Act & Assert + var _result = _msbuildFixture.RunDotnetExpectFailure(workingDirectory, $"restore {projectB.ProjectName}.csproj --configfile {pathContext.Settings.ConfigPath} -v d"); + tcpListenerServer.StopServer(); + } + + [PlatformFact(Platform.Windows)] + public async Task DotnetRestore_withAnotherSourceTLSCertificateValidationDisbaled_ThrowException() + { + // Arrange + using var pathContext = _msbuildFixture.CreateSimpleTestPathContext(); + TestDirectory packageSourceDirectory = TestDirectory.Create(); + var packageB100 = new SimpleTestPackageContext("myPackg", "1.0.0"); + await SimpleTestPackageUtility.CreateFolderFeedV3Async( + packageSourceDirectory, + PackageSaveMode.Defaultv3, + packageB100); + var projectB = XPlatTestUtils.CreateProject("ProjectB", pathContext, packageB100, "net472"); + var workingDirectory = Path.Combine(pathContext.SolutionRoot, projectB.ProjectName); + SelfSignedCertificateMockServer tcpListenerServer1 = new SelfSignedCertificateMockServer(packageSourceDirectory); + SelfSignedCertificateMockServer tcpListenerServer2 = new SelfSignedCertificateMockServer(packageSourceDirectory); + var serverTask = tcpListenerServer1.StartServerAsync(); + var serverTask2 = tcpListenerServer2.StartServerAsync(); + pathContext.Settings.AddSource("https-feed1", $"{tcpListenerServer1.URI}v3/index.json"); + pathContext.Settings.AddSource("https-feed2", $"{tcpListenerServer2.URI}v3/index.json", "disableTLSCertificateValidation", "true"); + + // Act & Assert + var _result = _msbuildFixture.RunDotnetExpectFailure(workingDirectory, $"restore {projectB.ProjectName}.csproj --configfile {pathContext.Settings.ConfigPath}"); + tcpListenerServer1.StopServer(); + tcpListenerServer2.StopServer(); + } + + [PlatformFact(Platform.Windows)] + public async Task DotnetRestore_withAnotherSourceTLSCertificateValidationEnabled_DoesNotThrowException() + { + // Arrange + using var pathContext = _msbuildFixture.CreateSimpleTestPathContext(); + TestDirectory packageSourceDirectory = TestDirectory.Create(); + var packageB100 = new SimpleTestPackageContext("myPackg", "1.0.0"); + await SimpleTestPackageUtility.CreateFolderFeedV3Async( + packageSourceDirectory, + PackageSaveMode.Defaultv3, + packageB100); + var projectB = XPlatTestUtils.CreateProject("ProjectB", pathContext, packageB100, "net472"); + var workingDirectory = Path.Combine(pathContext.SolutionRoot, projectB.ProjectName); + SelfSignedCertificateMockServer tcpListenerServer1 = new SelfSignedCertificateMockServer(packageSourceDirectory); + SelfSignedCertificateMockServer tcpListenerServer2 = new SelfSignedCertificateMockServer(packageSourceDirectory); + var serverTask = tcpListenerServer1.StartServerAsync(); + var serverTask2 = tcpListenerServer2.StartServerAsync(); + pathContext.Settings.AddSource("https-feed1", $"{tcpListenerServer1.URI}v3/index.json"); + pathContext.Settings.AddSource("https-feed2", $"{tcpListenerServer2.URI}v3/index.json", "disableTLSCertificateValidation", "true"); + + // Act & Assert + var _result = _msbuildFixture.RunDotnetExpectSuccess(workingDirectory, $"restore {projectB.ProjectName}.csproj --configfile {pathContext.Settings.ConfigPath} --source {tcpListenerServer2.URI}v3/index.json"); + tcpListenerServer1.StopServer(); + tcpListenerServer2.StopServer(); + } + } +} diff --git a/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceProviderTests.cs b/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceProviderTests.cs index 168687f5c45..21e4f720435 100644 --- a/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceProviderTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceProviderTests.cs @@ -888,6 +888,82 @@ public void LoadPackageSources_ReadsSourcesWithNotNullAllowInsecureConnectionsFr Assert.Equal(bool.Parse(allowInsecureConnections), loadedSource.AllowInsecureConnections); } + [Fact] + public void LoadPackageSources_ReadsSourcesWithNullDisableTLSCertificateVerificationFromPackageSourceSections_LoadsDefault() + { + // Arrange + var settings = new Mock(); + var sourceItem = new SourceItem("Source", "https://some-source.test", protocolVersion: null, allowInsecureConnections: null, disableTLSCertificateValidation: null); + + settings.Setup(s => s.GetSection("packageSources")) + .Returns(new VirtualSettingSection("packageSources", + sourceItem)); + + settings.Setup(s => s.GetConfigFilePaths()) + .Returns(new List()); + + // Act + List values = LoadPackageSources(settings.Object); + + // Assert + var loadedSource = values.Single(); + Assert.Equal("Source", loadedSource.Name); + Assert.Equal("https://some-source.test", loadedSource.Source); + Assert.Equal(PackageSource.DefaultDisableTLSCertificateValidation, loadedSource.DisableTLSCertificateValidation); + } + + [Fact] + public void LoadPackageSources_ReadsSourcesWithInvalidDisableTLSCertificateVerificationFromPackageSourceSections_LoadsDefault() + { + // Arrange + var settings = new Mock(); + var sourceItem = new SourceItem("Source", "https://some-source.test", protocolVersion: null, allowInsecureConnections: null, disableTLSCertificateValidation: "invalidValue"); + + settings.Setup(s => s.GetSection("packageSources")) + .Returns(new VirtualSettingSection("packageSources", + sourceItem)); + + settings.Setup(s => s.GetConfigFilePaths()) + .Returns(new List()); + + // Act + List values = LoadPackageSources(settings.Object); + + // Assert + var loadedSource = values.Single(); + Assert.Equal("Source", loadedSource.Name); + Assert.Equal("https://some-source.test", loadedSource.Source); + Assert.Equal(PackageSource.DefaultDisableTLSCertificateValidation, loadedSource.DisableTLSCertificateValidation); + } + + [Theory] + [InlineData("true")] + [InlineData("TRUE")] + [InlineData("false")] + [InlineData("fALSE")] + public void LoadPackageSources_ReadsSourcesWithNotNullDisableTLSCertificateVerificationFromPackageSourceSections_LoadsValue(string disableTLSCertificateValidation) + { + // Arrange + var settings = new Mock(); + var sourceItem = new SourceItem("Source", "https://some-source.test", protocolVersion: null, allowInsecureConnections: null, disableTLSCertificateValidation: disableTLSCertificateValidation); + + settings.Setup(s => s.GetSection("packageSources")) + .Returns(new VirtualSettingSection("packageSources", + sourceItem)); + + settings.Setup(s => s.GetConfigFilePaths()) + .Returns(new List()); + + // Act + List values = LoadPackageSources(settings.Object); + + // Assert + var loadedSource = values.Single(); + Assert.Equal("Source", loadedSource.Name); + Assert.Equal("https://some-source.test", loadedSource.Source); + Assert.Equal(bool.Parse(disableTLSCertificateValidation), loadedSource.DisableTLSCertificateValidation); + } + [Fact] public void DisablePackageSourceAddEntryToSettings() { diff --git a/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceTests.cs b/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceTests.cs index e679daa9032..ffe4d119c66 100644 --- a/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Configuration.Test/PackageSourceTests.cs @@ -18,7 +18,8 @@ public void Clone_CopiesAllPropertyValuesFromSource() { Credentials = credentials, ProtocolVersion = 43, - AllowInsecureConnections = true + AllowInsecureConnections = true, + DisableTLSCertificateValidation = true }; // Act @@ -32,6 +33,7 @@ public void Clone_CopiesAllPropertyValuesFromSource() Assert.Equal(source.IsEnabled, result.IsEnabled); Assert.Equal(source.ProtocolVersion, result.ProtocolVersion); Assert.Equal(source.AllowInsecureConnections, result.AllowInsecureConnections); + Assert.Equal(source.DisableTLSCertificateValidation, result.DisableTLSCertificateValidation); // source credential result.Credentials.Should().NotBeNull(); @@ -46,11 +48,12 @@ public void AsSourceItem_WorksCorrectly() var source = new PackageSource("Source", "SourceName", isEnabled: false) { ProtocolVersion = 43, - AllowInsecureConnections = true + AllowInsecureConnections = true, + DisableTLSCertificateValidation = true }; var result = source.AsSourceItem(); - var expectedItem = new SourceItem("SourceName", "Source", "43", "True"); + var expectedItem = new SourceItem("SourceName", "Source", "43", "True", "True"); SettingsTestUtils.DeepEquals(result, expectedItem).Should().BeTrue(); } diff --git a/test/NuGet.Core.Tests/NuGet.Configuration.Test/SettingsFileParsingTests/SourceItemTests.cs b/test/NuGet.Core.Tests/NuGet.Configuration.Test/SettingsFileParsingTests/SourceItemTests.cs index e4bc5953de5..0fc790bc370 100644 --- a/test/NuGet.Core.Tests/NuGet.Configuration.Test/SettingsFileParsingTests/SourceItemTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Configuration.Test/SettingsFileParsingTests/SourceItemTests.cs @@ -59,6 +59,9 @@ public void SourceItem_ParsedSuccessfully() + + + "; @@ -69,6 +72,9 @@ public void SourceItem_ParsedSuccessfully() new SourceItem("nuget3","http://serviceIndex.test3/api/index.json", protocolVersion: "3", allowInsecureConnections: "true" ), new SourceItem("nuget4","http://serviceIndex.test4/api/index.json", protocolVersion: null, allowInsecureConnections: "true" ), new SourceItem("nuget5","http://serviceIndex.test5/api/index.json", protocolVersion: "2", allowInsecureConnections: "false"), + new SourceItem("nuget6","http://serviceIndex.test6/api/index.json", protocolVersion: "3", allowInsecureConnections: "true", disableTLSCertificateValidation: "true"), + new SourceItem("nuget7","http://serviceIndex.test7/api/index.json", protocolVersion: null, allowInsecureConnections: "true", disableTLSCertificateValidation: "false"), + new SourceItem("nuget8","http://serviceIndex.test8/api/index.json", protocolVersion: "2", allowInsecureConnections: "false", disableTLSCertificateValidation: "true"), }; var nugetConfigPath = "NuGet.Config"; @@ -86,7 +92,7 @@ public void SourceItem_ParsedSuccessfully() var children = section!.Items.Cast().ToList(); children.Should().NotBeEmpty(); - children.Count.Should().Be(5); + children.Count.Should().Be(8); for (var i = 0; i < children.Count; i++) { @@ -95,6 +101,7 @@ public void SourceItem_ParsedSuccessfully() children[i].Value.Should().Be(expectedValues[i].Value, because: $"SourceItem[{i}].Value is {children[i].Value}, but it's expected to be {expectedValues[i].Value}"); children[i].ProtocolVersion.Should().Be(expectedValues[i].ProtocolVersion, because: $"SourceItem[{i}].ProtocolVersion is {children[i].ProtocolVersion}, but it's expected to be {expectedValues[i].ProtocolVersion}"); children[i].AllowInsecureConnections.Should().Be(expectedValues[i].AllowInsecureConnections, because: $"SourceItem[{i}].AllowInsecureConnections is {children[i].AllowInsecureConnections}, but it's expected to be {expectedValues[i].AllowInsecureConnections}"); + children[i].DisableTLSCertificateValidation.Should().Be(expectedValues[i].DisableTLSCertificateValidation, because: $"SourceItem[{i}].DisableTLSCertificateValidation is {children[i].DisableTLSCertificateValidation}, but it's expected to be {expectedValues[i].DisableTLSCertificateValidation}"); } } } @@ -107,7 +114,10 @@ public void SourceItem_AsXNode_ReturnsExpectedXNode() new SourceItem("nuget1", "http://serviceIndex.test1/api/index.json", protocolVersion: "3", allowInsecureConnections: "true"), new SourceItem("nuget2", "http://serviceIndex.test2/api/index.json", protocolVersion: "2", allowInsecureConnections: "false"), new SourceItem("nuget3", "http://serviceIndex.test3/api/index.json", protocolVersion: null, allowInsecureConnections: "true"), - new SourceItem("nuget4", "http://serviceIndex.test4/api/index.json", protocolVersion: "3"))); + new SourceItem("nuget4", "http://serviceIndex.test4/api/index.json", protocolVersion: "3"), + new SourceItem("nuget5", "http://serviceIndex.test5/api/index.json", protocolVersion: "3", allowInsecureConnections: "true", disableTLSCertificateValidation: "false"), + new SourceItem("nuget6", "http://serviceIndex.test6/api/index.json", protocolVersion: "2", allowInsecureConnections: "false", disableTLSCertificateValidation: "false"), + new SourceItem("nuget7", "http://serviceIndex.test7/api/index.json", protocolVersion: null, allowInsecureConnections: "true", disableTLSCertificateValidation: "true"))); var resultXml = SettingsTestUtils.RemoveWhitespace(configuration.AsXNode().ToString()); var expectedXNode = new XElement("configuration", @@ -129,7 +139,24 @@ public void SourceItem_AsXNode_ReturnsExpectedXNode() new XElement("add", new XAttribute("key", "nuget4"), new XAttribute("value", "http://serviceIndex.test4/api/index.json"), - new XAttribute("protocolVersion", "3")))); + new XAttribute("protocolVersion", "3")), + new XElement("add", + new XAttribute("key", "nuget5"), + new XAttribute("value", "http://serviceIndex.test5/api/index.json"), + new XAttribute("protocolVersion", "3"), + new XAttribute("allowInsecureConnections", "true"), + new XAttribute("disableTLSCertificateValidation", "false")), + new XElement("add", + new XAttribute("key", "nuget6"), + new XAttribute("value", "http://serviceIndex.test6/api/index.json"), + new XAttribute("protocolVersion", "2"), + new XAttribute("allowInsecureConnections", "false"), + new XAttribute("disableTLSCertificateValidation", "false")), + new XElement("add", + new XAttribute("key", "nuget7"), + new XAttribute("value", "http://serviceIndex.test7/api/index.json"), + new XAttribute("allowInsecureConnections", "true"), + new XAttribute("disableTLSCertificateValidation", "true")))); var expectedXml = SettingsTestUtils.RemoveWhitespace(expectedXNode.ToString()); resultXml.Should().Be(expectedXml, because: resultXml); diff --git a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/HttpHandlerResourceV3ProviderTests.cs b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/HttpHandlerResourceV3ProviderTests.cs index 2f6572d2ab5..74de98b391a 100644 --- a/test/NuGet.Core.Tests/NuGet.Protocol.Tests/HttpHandlerResourceV3ProviderTests.cs +++ b/test/NuGet.Core.Tests/NuGet.Protocol.Tests/HttpHandlerResourceV3ProviderTests.cs @@ -6,13 +6,17 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Security; +using System.Security.Authentication; using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Moq; using NuGet.Configuration; using NuGet.Protocol.Core.Types; +using NuGet.Test.Server; using NuGet.Test.Utility; +using Org.BouncyCastle.Asn1.X509; using Xunit; namespace NuGet.Protocol.Tests @@ -124,5 +128,135 @@ static IEnumerable GetDelegatingHandlers(HttpMessageHandler h } } } + + [Fact] + public async Task TryCreate_WhenCertificateValidationIsDisabled_HttpClientHandlerServerCertificateCustomValidationCallbackShouldNotBeNull() + { + // Arrange + Mock proxyCache = new(); + proxyCache.Setup(pc => pc.GetProxy(It.IsAny())).Returns((IWebProxy)null); + PackageSource packageSource = new(_testPackageSourceURL, "source") + { + DisableTLSCertificateValidation = true + }; + SourceRepository sourceRepository = new(packageSource, Array.Empty()); + HttpHandlerResourceV3Provider target = new(proxyCache.Object); + + // Act + var result = await target.TryCreate(sourceRepository, CancellationToken.None); + HttpHandlerResourceV3 resource = (HttpHandlerResourceV3)result.Item2; + HttpClientHandler clientHandler = resource.ClientHandler; + + // Assert + clientHandler.ServerCertificateCustomValidationCallback.Should().NotBeNull(); + } + + [Fact] + public async Task Invoke_WhenCertificateValidationIsDisabled_HttpClientHandlerServerCertificateCustomValidationCallbackReturnsTrue() + { + // Arrange + Mock proxyCache = new(); + proxyCache.Setup(pc => pc.GetProxy(It.IsAny())).Returns((IWebProxy)null); + PackageSource packageSource = new(_testPackageSourceURL, "source") + { + DisableTLSCertificateValidation = true + }; + SourceRepository sourceRepository = new(packageSource, Array.Empty()); + HttpHandlerResourceV3Provider target = new(proxyCache.Object); + var result = await target.TryCreate(sourceRepository, CancellationToken.None); + HttpHandlerResourceV3 resource = (HttpHandlerResourceV3)result.Item2; + HttpClientHandler clientHandler = resource.ClientHandler; + + // Act + var callbackResult = clientHandler.ServerCertificateCustomValidationCallback.Invoke(null, null, null, SslPolicyErrors.RemoteCertificateChainErrors + & SslPolicyErrors.RemoteCertificateNameMismatch + & SslPolicyErrors.RemoteCertificateNotAvailable + & SslPolicyErrors.None); + + // Assert + callbackResult.Should().BeTrue(); + } + + [Fact] + public async Task TryCreate_WhenCertificateValidationIsEnabled_HttpClientHandlerServerCertificateCustomValidationCallbackShouldBeNull() + { + // Arrange + Mock proxyCache = new(); + proxyCache.Setup(pc => pc.GetProxy(It.IsAny())).Returns((IWebProxy)null); + PackageSource packageSource = new(_testPackageSourceURL, "source") + { + DisableTLSCertificateValidation = false + }; + SourceRepository sourceRepository = new(packageSource, Array.Empty()); + HttpHandlerResourceV3Provider target = new(proxyCache.Object); + + // Act + var result = await target.TryCreate(sourceRepository, CancellationToken.None); + HttpHandlerResourceV3 resource = (HttpHandlerResourceV3)result.Item2; + HttpClientHandler clientHandler = resource.ClientHandler; + + // Assert + clientHandler.ServerCertificateCustomValidationCallback.Should().BeNull(); + } + + [Fact] + public async Task GetAsync_InvalidCertificateWithValidationEnabled_ClientHandlerThrowsAnException() + { + // Arrange + TcpListenerServer server = new() + { + Mode = TestServerMode.InvalidTLSCertificate + }; + + await server.ExecuteAsync(async uri => + { + Mock proxyCache = new(); + proxyCache.Setup(pc => pc.GetProxy(It.IsAny())).Returns((IWebProxy)null); + PackageSource packageSource = new(uri, "source"); + SourceRepository sourceRepository = new(packageSource, Array.Empty()); + HttpHandlerResourceV3Provider target = new(proxyCache.Object); + var result = await target.TryCreate(sourceRepository, CancellationToken.None); + HttpHandlerResourceV3 resource = (HttpHandlerResourceV3)result.Item2; + HttpClientHandler clientHandler = resource.ClientHandler; + var client = new HttpClient(clientHandler); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await client.GetAsync(uri)); + return 0; + }); + } + + [Fact] + public async Task GetAsync_InvalidCertificateWithValidationDisabled_ClientHandlerDoesNotThrowAnException() + { + // Arrange + TcpListenerServer server = new() + { + Mode = TestServerMode.InvalidTLSCertificate + }; + + await server.ExecuteAsync(async uri => + { + Mock proxyCache = new(); + proxyCache.Setup(pc => pc.GetProxy(It.IsAny())).Returns((IWebProxy)null); + PackageSource packageSource = new(uri, "source") + { + DisableTLSCertificateValidation = true + }; + SourceRepository sourceRepository = new(packageSource, Array.Empty()); + HttpHandlerResourceV3Provider target = new(proxyCache.Object); + var result = await target.TryCreate(sourceRepository, CancellationToken.None); + HttpHandlerResourceV3 resource = (HttpHandlerResourceV3)result.Item2; + HttpClientHandler clientHandler = resource.ClientHandler; + var client = new HttpClient(clientHandler); + + // Act + var response = await client.GetAsync(uri); + + // Assert + Assert.True(response.IsSuccessStatusCode); + return 0; + }); + } } } diff --git a/test/TestUtilities/Test.Utility/SelfSignedCertificateMockServer.cs b/test/TestUtilities/Test.Utility/SelfSignedCertificateMockServer.cs new file mode 100644 index 00000000000..1cbb486ccaa --- /dev/null +++ b/test/TestUtilities/Test.Utility/SelfSignedCertificateMockServer.cs @@ -0,0 +1,212 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NuGet.Test.Server; + +namespace Test.Utility +{ + public class SelfSignedCertificateMockServer + { + private readonly X509Certificate2 _certificate; + private readonly string _packageDirectory; + private TcpListener _tcpListener; + private bool _runServer = true; + public string URI; + + public SelfSignedCertificateMockServer(string packageDirectory) + { + _packageDirectory = packageDirectory; + _certificate = GenerateSelfSignedCertificate(); + } + + public async Task StartServerAsync() + { + var portReserver = new PortReserver(); + var portNumber = await portReserver.ExecuteAsync((p, t) => Task.FromResult(p), CancellationToken.None); + _tcpListener = new TcpListener(IPAddress.Loopback, portNumber); + URI = $"https://{_tcpListener.LocalEndpoint}/"; + _tcpListener.Start(); + + while (_runServer) + { + var client = await _tcpListener.AcceptTcpClientAsync(); + _ = Task.Run(() => HandleClient(client)); + } + } + + public void StopServer() + { + _runServer = false; + _tcpListener.Stop(); + } + + private async Task HandleClient(TcpClient client) + { + using (client) + using (var sslStream = new SslStream(client.GetStream(), false)) + { + await sslStream.AuthenticateAsServerAsync(_certificate, clientCertificateRequired: false, checkCertificateRevocation: true); + using (var reader = new StreamReader(sslStream, Encoding.ASCII, false, 128)) + using (var writer = new StreamWriter(sslStream, Encoding.ASCII, 128, false)) + { + try + { + var requestLine = await reader.ReadLineAsync(); + var requestParts = requestLine?.Split(' '); + + if (requestParts == null || requestParts.Length < 2) + { + throw new InvalidOperationException("Invalid HTTP request line."); + } + + string path = requestParts[1]; + var parts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + + if (path == "/v3/index.json") + { + SendIndexJsonResponse(writer); + } + else if (parts.Length > 1 && parts[0] == "v3") + { + if (parts[1] == "package") + { + if (parts.Length == 4) + { + ProcessPackageRequest(parts[2], writer); + } + else + { + SendPackageFile(parts[2], parts[3], parts[4], writer, sslStream); + } + } + else + { + await writer.WriteLineAsync("HTTP/1.1 404 Not Found"); + } + } + else + { + await writer.WriteLineAsync("HTTP/1.1 404 Not Found"); + } + } + catch (Exception ex) + { + Console.WriteLine("Error processing request: " + ex.Message); + } + } + } + } + + private void ProcessPackageRequest(string id, StreamWriter writer) + { + try + { + var versions = GetVersionsFromDirectory(id); + + var json = JsonConvert.SerializeObject(new { versions }); + writer.WriteLine("HTTP/1.1 200 OK"); + writer.WriteLine("Content-Type: application/json"); + writer.WriteLine(); + writer.WriteLine(json); + writer.Flush(); + } + catch (Exception ex) + { + Console.WriteLine($"Error processing request: {ex.Message}"); + } + } + + private void SendPackageFile(string id, string version, string nupkg, StreamWriter writer, SslStream sslStream) + { + var filePath = Path.Combine(_packageDirectory, id, version, nupkg); + + if (!File.Exists(filePath)) + { + writer.WriteLine("HTTP/1.1 404 Not Found"); + return; + } + + using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read)) + { + writer.WriteLine("HTTP/1.1 200 OK"); + writer.WriteLine("Content-Type: application/octet-stream"); + writer.WriteLine($"Content-Disposition: attachment; filename=\"{id}.{version}.nupkg\""); + writer.WriteLine($"Content-Length: {new FileInfo(filePath).Length}"); + writer.WriteLine(); + writer.Flush(); + + fileStream.CopyTo(sslStream); + } + } + + private string[] GetVersionsFromDirectory(string id) + { + var directoryPath = Path.Combine(_packageDirectory, id); + + if (!Directory.Exists(directoryPath)) + { + throw new DirectoryNotFoundException($"Directory not found: {directoryPath}"); + } + + var dirInfo = new DirectoryInfo(directoryPath); + return dirInfo.GetDirectories().Select(d => d.Name).ToArray(); + } + + private void SendIndexJsonResponse(StreamWriter writer) + { + var indexResponse = new + { + version = "3.0.0", + resources = new object[] + { + new Resource { Type = "SearchQueryService", Id = $"{URI}v3/query" }, + new Resource { Type = "RegistrationsBaseUrl", Id = $"{URI}v3/registration" }, + new Resource { Type = "PackageBaseAddress/3.0.0", Id = $"{URI}v3/package" }, + new Resource { Type = "PackagePublish/2.0.0", Id = $"{URI}v3/packagepublish" } + } + }; + + string jsonResponse = JsonConvert.SerializeObject(indexResponse); + writer.WriteLine("HTTP/1.1 200 OK"); + writer.WriteLine("Content-Type: application/json"); + writer.WriteLine(); + writer.WriteLine(jsonResponse); + writer.Flush(); + } + + private static X509Certificate2 GenerateSelfSignedCertificate() + { + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest("cn=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var start = DateTime.UtcNow; + var end = DateTime.UtcNow.AddYears(1); + var cert = request.CreateSelfSigned(start, end); + var certBytes = cert.Export(X509ContentType.Pfx, "password"); + return new X509Certificate2(certBytes, "password", X509KeyStorageFlags.Exportable); + } + } + } + + internal class Resource + { + [JsonProperty("@type")] + public string Type { get; set; } + + [JsonProperty("@id")] + public string Id { get; set; } + } +} diff --git a/test/TestUtilities/Test.Utility/SimpleTestSetup/SimpleTestSettingsContext.cs b/test/TestUtilities/Test.Utility/SimpleTestSetup/SimpleTestSettingsContext.cs index dd2c9cd16bf..ba28839f557 100644 --- a/test/TestUtilities/Test.Utility/SimpleTestSetup/SimpleTestSettingsContext.cs +++ b/test/TestUtilities/Test.Utility/SimpleTestSetup/SimpleTestSettingsContext.cs @@ -226,6 +226,13 @@ public void AddSource(string sourceName, string sourceUri, string allowInsecureC Save(); } + public void AddSource(string sourceName, string sourceUri, string attributeName, string attributeValue) + { + var section = GetOrAddSection(XML, "packageSources"); + AddEntry(section, sourceName, sourceUri, attributeName, attributeValue); + Save(); + } + public void AddPackageSourceMapping(string sourceName, params string[] patterns) { XElement packageSourceMappingSection = GetOrAddSection(XML, "packageSourceMapping"); diff --git a/test/TestUtilities/Test.Utility/TestServer/ITestServer.cs b/test/TestUtilities/Test.Utility/TestServer/ITestServer.cs index 0a618c5da7e..fc3c288b9f7 100644 --- a/test/TestUtilities/Test.Utility/TestServer/ITestServer.cs +++ b/test/TestUtilities/Test.Utility/TestServer/ITestServer.cs @@ -11,7 +11,8 @@ public enum TestServerMode ConnectFailure, ServerProtocolViolation, NameResolutionFailure, - SlowResponseBody + SlowResponseBody, + InvalidTLSCertificate, } public interface ITestServer diff --git a/test/TestUtilities/Test.Utility/TestServer/TcpListenerServer.cs b/test/TestUtilities/Test.Utility/TestServer/TcpListenerServer.cs index f565a6831fb..db8034c083c 100644 --- a/test/TestUtilities/Test.Utility/TestServer/TcpListenerServer.cs +++ b/test/TestUtilities/Test.Utility/TestServer/TcpListenerServer.cs @@ -5,14 +5,18 @@ using System.IO; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography.X509Certificates; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Net.Security; namespace NuGet.Test.Server { public class TcpListenerServer : ITestServer { + private X509Certificate2 _tlsCertificate; public async Task ExecuteAsync(Func> action) { Func startServer; @@ -25,6 +29,10 @@ public async Task ExecuteAsync(Func> action) case TestServerMode.SlowResponseBody: startServer = StartSlowResponseBody; break; + case TestServerMode.InvalidTLSCertificate: + startServer = StartInvalidTlsCertificateServer; + _tlsCertificate = GenerateSelfSignedCertificate(); + break; default: throw new InvalidOperationException($"The mode {Mode} is not supported by this server."); @@ -39,7 +47,16 @@ public async Task ExecuteAsync(Func> action) var tcpListener = new TcpListener(IPAddress.Loopback, port); tcpListener.Start(); var serverTask = startServer(tcpListener, serverCts.Token); - var address = $"http://localhost:{port}/"; + string address; + + if (Mode == TestServerMode.InvalidTLSCertificate) + { + address = $"https://localhost:{port}/"; + } + else + { + address = $"http://localhost:{port}/"; + } // execute the caller's action var result = await action(address); @@ -53,6 +70,50 @@ public async Task ExecuteAsync(Func> action) CancellationToken.None); } + public TcpListenerServer() + { } + + private static X509Certificate2 GenerateSelfSignedCertificate() + { + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest("cn=test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + var start = DateTime.UtcNow; + var end = DateTime.UtcNow.AddYears(1); + var cert = request.CreateSelfSigned(start, end); + var certBytes = cert.Export(X509ContentType.Pfx, "password"); + + return new X509Certificate2(certBytes, "password", X509KeyStorageFlags.Exportable); + } + } + + private async Task StartInvalidTlsCertificateServer(TcpListener tcpListener, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + using (var client = await Task.Run(tcpListener.AcceptTcpClientAsync, token)) + using (var sslStream = new SslStream(client.GetStream(), false)) + { + sslStream.AuthenticateAsServer(_tlsCertificate, clientCertificateRequired: false, checkCertificateRevocation: true); + using (var reader = new StreamReader(sslStream, Encoding.ASCII, false, 128)) + using (var writer = new StreamWriter(sslStream, Encoding.ASCII, 128, false)) + { + while (!string.IsNullOrEmpty(reader.ReadLine())) + { + } + + string content = "{}"; + writer.WriteLine("HTTP/1.1 200 OK"); + writer.WriteLine($"Date: {DateTimeOffset.UtcNow:R}"); + writer.WriteLine($"Content-Length: {content.Length}"); + writer.WriteLine("Content-Type: application/json"); + writer.WriteLine(); + writer.WriteLine(content); + } + } + } + } + public TestServerMode Mode { get; set; } = TestServerMode.ServerProtocolViolation; public TimeSpan SleepDuration { get; set; } = TimeSpan.FromSeconds(110);