diff --git a/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.csproj index 39a46bbae2..856e59f6ca 100644 --- a/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/ref/Microsoft.AspNetCore.Components.Server.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Components/Server/src/CircuitOptions.cs b/src/Components/Server/src/CircuitOptions.cs index 8820fbab15..d4f444fe91 100644 --- a/src/Components/Server/src/CircuitOptions.cs +++ b/src/Components/Server/src/CircuitOptions.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using Microsoft.AspNetCore.DataProtection; namespace Microsoft.AspNetCore.Components.Server { diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 955f26aa93..1ea6eba1c7 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -50,6 +50,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public event UnhandledExceptionEventHandler UnhandledException; public CircuitHost( + string circuitId, IServiceScope scope, CircuitClientProxy client, RendererRegistry rendererRegistry, @@ -60,6 +61,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits CircuitHandler[] circuitHandlers, ILogger logger) { + CircuitId = circuitId; _scope = scope ?? throw new ArgumentNullException(nameof(scope)); Dispatcher = dispatcher; Client = client; @@ -78,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits Renderer.UnhandledSynchronizationException += SynchronizationContext_UnhandledException; } - public string CircuitId { get; } = Guid.NewGuid().ToString(); + public string CircuitId { get; } public Circuit Circuit { get; } diff --git a/src/Components/Server/src/Circuits/CircuitIdFactory.cs b/src/Components/Server/src/Circuits/CircuitIdFactory.cs new file mode 100644 index 0000000000..830ac8d51a --- /dev/null +++ b/src/Components/Server/src/Circuits/CircuitIdFactory.cs @@ -0,0 +1,57 @@ +// 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.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Components.Server.Circuits +{ + // This is a singleton instance + // Generates strong cryptographic ids for circuits that are protected with authenticated encryption. + internal class CircuitIdFactory + { + private const string CircuitIdProtectorPurpose = "Microsoft.AspNetCore.Components.Server"; + + private readonly RandomNumberGenerator _generator = RandomNumberGenerator.Create(); + private readonly IDataProtector _protector; + + public CircuitIdFactory(IDataProtectionProvider provider) + { + _protector = provider.CreateProtector(CircuitIdProtectorPurpose); + } + + // Generates a circuit id that is produced from a strong cryptographic random number generator + // we don't care about the underlying payload, other than its uniqueness and the fact that we + // authenticate encrypt it using data protection. + // For validation, the fact that we can unprotect the payload is guarantee enough. + public string CreateCircuitId() + { + var buffer = new byte[32]; + _generator.GetBytes(buffer); + var payload = _protector.Protect(buffer); + + return Base64UrlTextEncoder.Encode(payload); + } + + public bool ValidateCircuitId(string circuitId) + { + try + { + var protectedBytes = Base64UrlTextEncoder.Decode(circuitId); + _protector.Unprotect(protectedBytes); + + // Its enough that we prove that we can unprotect the payload to validate the circuit id, + // as this demonstrates that it the id wasn't tampered with. + return true; + } + catch (Exception) + { + // The payload format is not correct (either not base64urlencoded or not data protected) + return false; + } + } + } +} diff --git a/src/Components/Server/src/Circuits/CircuitRegistry.cs b/src/Components/Server/src/Circuits/CircuitRegistry.cs index ac9c3eef44..d101af8a38 100644 --- a/src/Components/Server/src/Circuits/CircuitRegistry.cs +++ b/src/Components/Server/src/Circuits/CircuitRegistry.cs @@ -6,7 +6,6 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -41,15 +40,17 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits private readonly object CircuitRegistryLock = new object(); private readonly CircuitOptions _options; private readonly ILogger _logger; + private readonly CircuitIdFactory _circuitIdFactory; private readonly PostEvictionCallbackRegistration _postEvictionCallback; public CircuitRegistry( IOptions options, - ILogger logger) + ILogger logger, + CircuitIdFactory circuitIdFactory) { _options = options.Value; _logger = logger; - + _circuitIdFactory = circuitIdFactory; ConnectedCircuits = new ConcurrentDictionary(StringComparer.Ordinal); DisconnectedCircuits = new MemoryCache(new MemoryCacheOptions @@ -139,6 +140,11 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public virtual async Task ConnectAsync(string circuitId, IClientProxy clientProxy, string connectionId, CancellationToken cancellationToken) { + if (!_circuitIdFactory.ValidateCircuitId(circuitId)) + { + return null; + } + CircuitHost circuitHost; bool previouslyConnected; diff --git a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs index d2924cd795..913faa3c86 100644 --- a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs +++ b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs @@ -21,13 +21,16 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits { private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerFactory _loggerFactory; + private readonly CircuitIdFactory _circuitIdFactory; public DefaultCircuitFactory( IServiceScopeFactory scopeFactory, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + CircuitIdFactory circuitIdFactory) { _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _loggerFactory = loggerFactory; + _circuitIdFactory = circuitIdFactory ?? throw new ArgumentNullException(nameof(circuitIdFactory)); } public override CircuitHost CreateCircuitHost( @@ -81,6 +84,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits .ToArray(); var circuitHost = new CircuitHost( + _circuitIdFactory.CreateCircuitId(), scope, client, rendererRegistry, diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index e5fd5f2aca..31cdcce444 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -28,6 +28,8 @@ namespace Microsoft.Extensions.DependencyInjection { var builder = new DefaultServerSideBlazorBuilder(services); + services.AddDataProtection(); + // This call INTENTIONALLY uses the AddHubOptions on the SignalR builder, because it will merge // the global HubOptions before running the configure callback. We want to ensure that happens // once. Our AddHubOptions method doesn't do this. @@ -51,6 +53,9 @@ namespace Microsoft.Extensions.DependencyInjection // Components entrypoints, this lot is the same and repeated registrations are a no-op. services.TryAddEnumerable(ServiceDescriptor.Singleton, ConfigureStaticFilesOptions>()); services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddScoped(s => s.GetRequiredService().Circuit); services.TryAddScoped(); diff --git a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj index 1d2eb08cb1..64c49afc9d 100644 --- a/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj +++ b/src/Components/Server/src/Microsoft.AspNetCore.Components.Server.csproj @@ -1,4 +1,4 @@ - + netcoreapp3.0 @@ -13,6 +13,7 @@ + @@ -52,10 +53,7 @@ - + diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index 507ff4e315..73c9681903 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -28,6 +28,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits var serviceScope = new Mock(); var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher()); var circuitHost = TestCircuitHost.Create( + Guid.NewGuid().ToString(), serviceScope.Object, remoteRenderer); @@ -46,6 +47,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits var serviceScope = new Mock(); var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher()); var circuitHost = TestCircuitHost.Create( + Guid.NewGuid().ToString(), serviceScope.Object, remoteRenderer); diff --git a/src/Components/Server/test/Circuits/CircuitIdFactoryTest.cs b/src/Components/Server/test/Circuits/CircuitIdFactoryTest.cs new file mode 100644 index 0000000000..50b5f1701e --- /dev/null +++ b/src/Components/Server/test/Circuits/CircuitIdFactoryTest.cs @@ -0,0 +1,89 @@ +// 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.Linq; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Server.Circuits +{ + public class CircuitIdFactoryTest + { + [Fact] + public void CreateCircuitId_Generates_NewRandomId() + { + var factory = TestCircuitIdFactory.CreateTestFactory(); + + // Act + var id = factory.CreateCircuitId(); + + // Assert + Assert.NotNull(id); + // This is the magic data protection header that validates its protected + Assert.StartsWith("CfDJ", id); + } + + [Fact] + public void CreateCircuitId_Generates_GeneratesDifferentIds_ForSuccesiveCalls() + { + // Arrange + var factory = TestCircuitIdFactory.CreateTestFactory(); + + // Act + var ids = Enumerable.Range(0, 100).Select(i => factory.CreateCircuitId()).ToArray(); + + // Assert + Assert.All(ids, id => Assert.NotNull(id)); + Assert.Equal(100, ids.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void CircuitIds_Roundtrip() + { + // Arrange + var factory = TestCircuitIdFactory.CreateTestFactory(); + var id = factory.CreateCircuitId(); + + // Act + var isValid = factory.ValidateCircuitId(id); + + // Assert + Assert.True(isValid, "Failed to validate id"); + } + + [Fact] + public void ValidateCircuitId_ReturnsFalseForMalformedPayloads() + { + // Arrange + var factory = TestCircuitIdFactory.CreateTestFactory(); + + // Act + var isValid = factory.ValidateCircuitId("$%@&=="); + + // Assert + Assert.False(isValid, "Accepted an invalid payload"); + } + + [Fact] + public void ValidateCircuitId_ReturnsFalseForPotentiallyTamperedPayloads() + { + // Arrange + var factory = TestCircuitIdFactory.CreateTestFactory(); + var id = factory.CreateCircuitId(); + var protectedBytes = Base64UrlTextEncoder.Decode(id); + for (int i = protectedBytes.Length - 10; i < protectedBytes.Length; i++) + { + protectedBytes[i] = 0; + } + var tamperedId = Base64UrlTextEncoder.Encode(protectedBytes); + + // Act + var isValid = factory.ValidateCircuitId(tamperedId); + + // Assert + Assert.False(isValid, "Accepted a tampered payload"); + } + } +} diff --git a/src/Components/Server/test/Circuits/CircuitPrerendererTest.cs b/src/Components/Server/test/Circuits/CircuitPrerendererTest.cs index 9f2c4ec66f..b96541455c 100644 --- a/src/Components/Server/test/Circuits/CircuitPrerendererTest.cs +++ b/src/Components/Server/test/Circuits/CircuitPrerendererTest.cs @@ -35,7 +35,10 @@ namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits { // Arrange var circuitFactory = new TestCircuitFactory(); - var circuitRegistry = new CircuitRegistry(Options.Create(new CircuitOptions()), Mock.Of>()); + var circuitRegistry = new CircuitRegistry( + Options.Create(new CircuitOptions()), + Mock.Of>(), + TestCircuitIdFactory.CreateTestFactory()); var circuitPrerenderer = new CircuitPrerenderer(circuitFactory, circuitRegistry); var httpContext = new DefaultHttpContext(); var httpRequest = httpContext.Request; @@ -76,7 +79,10 @@ namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits { // Arrange var circuitFactory = new TestCircuitFactory(); - var circuitRegistry = new CircuitRegistry(Options.Create(new CircuitOptions()), Mock.Of>()); + var circuitRegistry = new CircuitRegistry( + Options.Create(new CircuitOptions()), + Mock.Of>(), + TestCircuitIdFactory.CreateTestFactory()); var circuitPrerenderer = new CircuitPrerenderer(circuitFactory, circuitRegistry); var httpContext = new DefaultHttpContext(); var httpRequest = httpContext.Request; @@ -117,7 +123,7 @@ namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits return uriHelper; }); var serviceScope = serviceCollection.BuildServiceProvider().CreateScope(); - return TestCircuitHost.Create(serviceScope); + return TestCircuitHost.Create(Guid.NewGuid().ToString(), serviceScope); } } diff --git a/src/Components/Server/test/Circuits/CircuitRegistryTest.cs b/src/Components/Server/test/Circuits/CircuitRegistryTest.cs index d0da0cf00d..54e85ba323 100644 --- a/src/Components/Server/test/Circuits/CircuitRegistryTest.cs +++ b/src/Components/Server/test/Circuits/CircuitRegistryTest.cs @@ -4,6 +4,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; @@ -34,8 +35,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task ConnectAsync_TransfersClientOnActiveCircuit() { // Arrange - var registry = CreateRegistry(); - var circuitHost = TestCircuitHost.Create(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + + var registry = CreateRegistry(circuitIdFactory); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId()); registry.Register(circuitHost); var newClient = Mock.Of(); @@ -57,8 +60,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task ConnectAsync_MakesInactiveCircuitActive() { // Arrange - var registry = CreateRegistry(); - var circuitHost = TestCircuitHost.Create(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + + var registry = CreateRegistry(circuitIdFactory); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId()); registry.DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, new MemoryCacheEntryOptions { Size = 1 }); var newClient = Mock.Of(); @@ -81,9 +86,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task ConnectAsync_InvokesCircuitHandlers_WhenCircuitWasPreviouslyDisconnected() { // Arrange - var registry = CreateRegistry(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + var registry = CreateRegistry(circuitIdFactory); var handler = new Mock { CallBase = true }; - var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object }); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId(), handlers: new[] { handler.Object }); registry.DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, new MemoryCacheEntryOptions { Size = 1 }); var newClient = Mock.Of(); @@ -104,9 +110,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task ConnectAsync_InvokesCircuitHandlers_WhenCircuitWasConsideredConnected() { // Arrange - var registry = CreateRegistry(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + var registry = CreateRegistry(circuitIdFactory); var handler = new Mock { CallBase = true }; - var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object }); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId(), handlers: new[] { handler.Object }); registry.Register(circuitHost); var newClient = Mock.Of(); @@ -199,11 +206,13 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task Connect_WhileDisconnectIsInProgress() { // Arrange - var registry = new TestCircuitRegistry(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + + var registry = new TestCircuitRegistry(circuitIdFactory); registry.BeforeDisconnect = new ManualResetEventSlim(); var tcs = new TaskCompletionSource(); - var circuitHost = TestCircuitHost.Create(); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId()); registry.Register(circuitHost); var client = Mock.Of(); var newId = "new-connection"; @@ -238,13 +247,15 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task Connect_WhileDisconnectIsInProgress_SeriallyExecutesCircuitHandlers() { // Arrange - var registry = new TestCircuitRegistry(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + + var registry = new TestCircuitRegistry(circuitIdFactory); registry.BeforeDisconnect = new ManualResetEventSlim(); // This verifies that connection up \ down events on a circuit handler are always invoked serially. var circuitHandler = new SerialCircuitHandler(); var tcs = new TaskCompletionSource(); - var circuitHost = TestCircuitHost.Create(handlers: new[] { circuitHandler }); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId(), handlers: new[] { circuitHandler }); registry.Register(circuitHost); var client = Mock.Of(); var newId = "new-connection"; @@ -276,9 +287,11 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits public async Task DisconnectWhenAConnectIsInProgress() { // Arrange - var registry = new TestCircuitRegistry(); + var circuitIdFactory = TestCircuitIdFactory.CreateTestFactory(); + + var registry = new TestCircuitRegistry(circuitIdFactory); registry.BeforeConnect = new ManualResetEventSlim(); - var circuitHost = TestCircuitHost.Create(); + var circuitHost = TestCircuitHost.Create(circuitIdFactory.CreateCircuitId()); registry.Register(circuitHost); var client = Mock.Of(); var oldId = circuitHost.Client.ConnectionId; @@ -302,8 +315,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits private class TestCircuitRegistry : CircuitRegistry { - public TestCircuitRegistry() - : base(Options.Create(new CircuitOptions()), NullLogger.Instance) + public TestCircuitRegistry(CircuitIdFactory factory) + : base(Options.Create(new CircuitOptions()), NullLogger.Instance, factory) { } @@ -331,11 +344,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits } } - private static CircuitRegistry CreateRegistry() + private static CircuitRegistry CreateRegistry(CircuitIdFactory factory = null) { return new CircuitRegistry( Options.Create(new CircuitOptions()), - NullLogger.Instance); + NullLogger.Instance, + factory ?? TestCircuitIdFactory.CreateTestFactory()); } private class SerialCircuitHandler : CircuitHandler diff --git a/src/Components/Server/test/Circuits/TestCircuitHost.cs b/src/Components/Server/test/Circuits/TestCircuitHost.cs index c07fb92932..26ca1f224d 100644 --- a/src/Components/Server/test/Circuits/TestCircuitHost.cs +++ b/src/Components/Server/test/Circuits/TestCircuitHost.cs @@ -18,8 +18,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits { internal class TestCircuitHost : CircuitHost { - private TestCircuitHost(IServiceScope scope, CircuitClientProxy client, RendererRegistry rendererRegistry, RemoteRenderer renderer, IList descriptors, IDispatcher dispatcher, RemoteJSRuntime jsRuntime, CircuitHandler[] circuitHandlers, ILogger logger) - : base(scope, client, rendererRegistry, renderer, descriptors, dispatcher, jsRuntime, circuitHandlers, logger) + private TestCircuitHost(string circuitId, IServiceScope scope, CircuitClientProxy client, RendererRegistry rendererRegistry, RemoteRenderer renderer, IList descriptors, IDispatcher dispatcher, RemoteJSRuntime jsRuntime, CircuitHandler[] circuitHandlers, ILogger logger) + : base(circuitId, scope, client, rendererRegistry, renderer, descriptors, dispatcher, jsRuntime, circuitHandlers, logger) { } @@ -29,6 +29,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits } public static CircuitHost Create( + string circuitId = null, IServiceScope serviceScope = null, RemoteRenderer remoteRenderer = null, CircuitHandler[] handlers = null, @@ -54,6 +55,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits handlers = handlers ?? Array.Empty(); return new TestCircuitHost( + circuitId ?? Guid.NewGuid().ToString(), serviceScope, clientProxy, renderRegistry, diff --git a/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs b/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs new file mode 100644 index 0000000000..18dca9b964 --- /dev/null +++ b/src/Components/Server/test/Circuits/TestCircuitIdFactory.cs @@ -0,0 +1,15 @@ +// 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 Microsoft.AspNetCore.DataProtection; + +namespace Microsoft.AspNetCore.Components.Server.Circuits +{ + internal class TestCircuitIdFactory + { + public static CircuitIdFactory CreateTestFactory() + { + return new CircuitIdFactory(new EphemeralDataProtectionProvider()); + } + } +} diff --git a/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs b/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs index 533cd19c7e..ad1d0ae013 100644 --- a/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs +++ b/src/Mvc/Mvc.Components.Prerendering/test/HtmlHelperComponentPrerenderingExtensionsTests.cs @@ -432,6 +432,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures { var services = new ServiceCollection(); services.AddLogging(); + services.AddDataProtection(); services.AddSingleton(HtmlEncoder.Default); configureServices = configureServices ?? (s => s.AddServerSideBlazor()); configureServices?.Invoke(services);