Skip to content

Commit f5853af

Browse files
authored
[Blazor] Enable websocket compression for Blazor Server and Interactive Server components in Blazor web (#53389)
* Enables websocket compression by default on interactive server components. * A new overload of `AddServerRenderMode` allows configuring the compression parameters as well as disabling it by setting the websocket callback to `null`. * The `ContentSecurityAncestorPolicy` limits the ability to render the app/page inside an iframe from different origins. It can be disabled by setting the value to null (for example, if the policy is configured elsewhere) or restricted even further providing the value 'none', to constrain the policy even further.
1 parent b99ab85 commit f5853af

23 files changed

+566
-28
lines changed

src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ internal void AddEndpoints(
5151
builder.Metadata.Add(new RootComponentMetadata(rootComponent));
5252
builder.Metadata.Add(configuredRenderModesMetadata);
5353

54+
builder.RequestDelegate = static httpContext =>
55+
{
56+
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
57+
return invoker.Render(httpContext);
58+
};
59+
5460
foreach (var convention in conventions)
5561
{
5662
convention(builder);
@@ -67,12 +73,6 @@ internal void AddEndpoints(
6773
// The display name is for debug purposes by endpoint routing.
6874
builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})";
6975

70-
builder.RequestDelegate = httpContext =>
71-
{
72-
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
73-
return invoker.Render(httpContext);
74-
};
75-
7676
endpoints.Add(builder.Build());
7777
}
7878
}

src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ private static IEndpointConventionBuilder GetBlazorEndpoint(IEndpointRouteBuilde
115115
.WithDisplayName("Blazor static files");
116116

117117
blazorEndpoint.Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);
118-
118+
119119
#if DEBUG
120120
// We only need to serve the sourcemap when working on the framework, not in the distributed packages
121121
endpoints.Map("/_framework/blazor.server.js.map", app.Build())
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Microsoft.AspNetCore.Components.Web;
55

6-
namespace Microsoft.AspNetCore.Builder;
6+
namespace Microsoft.AspNetCore.Components.Server;
77

8-
internal class InternalServerRenderMode : InteractiveServerRenderMode
8+
internal class InternalServerRenderMode(ServerComponentsEndpointOptions options) : InteractiveServerRenderMode
99
{
10+
public ServerComponentsEndpointOptions? Options { get; } = options;
1011
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Http;
5+
6+
namespace Microsoft.AspNetCore.Components.Server;
7+
8+
/// <summary>
9+
/// Options to configure interactive Server components.
10+
/// </summary>
11+
public class ServerComponentsEndpointOptions
12+
{
13+
/// <summary>
14+
/// Gets or sets the <c>frame-ancestors</c> <c>Content-Security-Policy</c> to set in the
15+
/// <see cref="HttpResponse"/> when <see cref="ConfigureWebsocketOptions" /> is set.
16+
/// </summary>
17+
/// <remarks>
18+
/// <para>Setting this value to <see langword="null" /> will prevent the policy from being
19+
/// automatically applied, which might make the app vulnerable. Care must be taken to apply
20+
/// a policy in this case whenever the first document is rendered.
21+
/// </para>
22+
/// <para>
23+
/// A content security policy provides defense against security threats that can occur if
24+
/// the app uses compression and can be embedded in other origins. When compression is enabled,
25+
/// embedding the app inside an <c>iframe</c> from other origins is prohibited.
26+
/// </para>
27+
/// <para>
28+
/// For more details see the security recommendations for Interactive Server Components in
29+
/// the official documentation.
30+
/// </para>
31+
/// </remarks>
32+
public string? ContentSecurityFrameAncestorPolicy { get; set; } = "'self'";
33+
34+
/// <summary>
35+
/// Gets or sets a function to configure the <see cref="WebSocketAcceptContext"/> for the websocket connections
36+
/// used by the server components.
37+
/// By default, a policy that enables compression and sets a Content Security Policy for the frame ancestors
38+
/// defined in <see cref="ContentSecurityFrameAncestorPolicy"/> will be applied.
39+
/// </summary>
40+
public Func<HttpContext, WebSocketAcceptContext>? ConfigureWebsocketOptions { get; set; } = EnableCompressionDefaults;
41+
42+
private static WebSocketAcceptContext EnableCompressionDefaults(HttpContext context) =>
43+
new() { DangerousEnableCompression = true };
44+
}

src/Components/Server/src/Builder/ServerRazorComponentsEndpointConventionBuilderExtensions.cs

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.AspNetCore.Components.Endpoints;
45
using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
6+
using Microsoft.AspNetCore.Components.Server;
57
using Microsoft.AspNetCore.Components.Web;
8+
using Microsoft.AspNetCore.SignalR;
69

710
namespace Microsoft.AspNetCore.Builder;
811

@@ -17,7 +20,44 @@ public static class ServerRazorComponentsEndpointConventionBuilderExtensions
1720
/// <returns>The <see cref="RazorComponentsEndpointConventionBuilder"/>.</returns>
1821
public static RazorComponentsEndpointConventionBuilder AddInteractiveServerRenderMode(this RazorComponentsEndpointConventionBuilder builder)
1922
{
20-
ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new InternalServerRenderMode());
23+
return AddInteractiveServerRenderMode(builder, null);
24+
}
25+
26+
/// <summary>
27+
/// Maps the Blazor <see cref="Hub" /> to the default path.
28+
/// </summary>
29+
/// <param name="builder">The <see cref="RazorComponentsEndpointConventionBuilder"/>.</param>
30+
/// <param name="callback">A callback to configure server endpoint options.</param>
31+
/// <returns>The <see cref="ComponentEndpointConventionBuilder"/>.</returns>
32+
public static RazorComponentsEndpointConventionBuilder AddInteractiveServerRenderMode(
33+
this RazorComponentsEndpointConventionBuilder builder,
34+
Action<ServerComponentsEndpointOptions>? callback = null)
35+
{
36+
var options = new ServerComponentsEndpointOptions();
37+
callback?.Invoke(options);
38+
39+
ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new InternalServerRenderMode(options));
40+
41+
if (options.ConfigureWebsocketOptions is not null && options.ContentSecurityFrameAncestorPolicy != null)
42+
{
43+
builder.Add(b =>
44+
{
45+
for (var i = 0; i < b.Metadata.Count; i++)
46+
{
47+
var metadata = b.Metadata[i];
48+
if (metadata is ComponentTypeMetadata)
49+
{
50+
var original = b.RequestDelegate;
51+
b.RequestDelegate = async context =>
52+
{
53+
context.Response.Headers.Add("Content-Security-Policy", $"frame-ancestors {options.ContentSecurityFrameAncestorPolicy}");
54+
await original(context);
55+
};
56+
}
57+
}
58+
});
59+
}
60+
2161
return builder;
2262
}
2363
}

src/Components/Server/src/DependencyInjection/ServerRazorComponentsBuilderExtensions.cs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics.CodeAnalysis;
5+
using System.Net.WebSockets;
56
using Microsoft.AspNetCore.Builder;
67
using Microsoft.AspNetCore.Components;
78
using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
89
using Microsoft.AspNetCore.Components.Server;
910
using Microsoft.AspNetCore.Components.Web;
11+
using Microsoft.AspNetCore.Http;
12+
using Microsoft.AspNetCore.Http.Connections;
13+
using Microsoft.AspNetCore.Http.Features;
1014
using Microsoft.AspNetCore.Routing;
15+
using Microsoft.AspNetCore.SignalR;
1116
using Microsoft.Extensions.DependencyInjection.Extensions;
1217

1318
namespace Microsoft.Extensions.DependencyInjection;
@@ -60,11 +65,46 @@ public override IEnumerable<RouteEndpointBuilder> GetEndpointBuilders(
6065
throw new InvalidOperationException("Invalid render mode. Use AddInteractiveServerRenderMode() to configure the Server render mode.");
6166
}
6267

63-
return Array.Empty<RouteEndpointBuilder>();
68+
return [];
6469
}
6570

6671
var endpointRouteBuilder = new EndpointRouteBuilder(Services, applicationBuilder);
67-
endpointRouteBuilder.MapBlazorHub();
72+
var hub = endpointRouteBuilder.MapBlazorHub("/_blazor");
73+
74+
if (renderMode is InternalServerRenderMode { Options.ConfigureWebsocketOptions: { } configureConnection })
75+
{
76+
hub.Finally(c =>
77+
{
78+
for (var i = 0; i < c.Metadata.Count; i++)
79+
{
80+
var metadata = c.Metadata[i];
81+
if (metadata is NegotiateMetadata)
82+
{
83+
return;
84+
}
85+
86+
if (metadata is HubMetadata)
87+
{
88+
var originalDelegate = c.RequestDelegate;
89+
var builder = endpointRouteBuilder.CreateApplicationBuilder();
90+
builder.UseWebSockets();
91+
builder.Use(static (ctx, nxt) =>
92+
{
93+
if (ctx.WebSockets.IsWebSocketRequest)
94+
{
95+
var currentFeature = ctx.Features.Get<IHttpWebSocketFeature>();
96+
97+
ctx.Features.Set<IHttpWebSocketFeature>(new ServerComponentsSocketFeature(currentFeature!));
98+
}
99+
return nxt(ctx);
100+
});
101+
builder.Run(originalDelegate);
102+
c.RequestDelegate = builder.Build();
103+
return;
104+
}
105+
}
106+
});
107+
}
68108

69109
return endpointRouteBuilder.GetEndpoints();
70110
}
@@ -115,6 +155,18 @@ internal IEnumerable<RouteEndpointBuilder> GetEndpoints()
115155
}
116156
}
117157
}
158+
159+
}
160+
161+
private sealed class ServerComponentsSocketFeature(IHttpWebSocketFeature originalFeature) : IHttpWebSocketFeature
162+
{
163+
public bool IsWebSocketRequest => originalFeature.IsWebSocketRequest;
164+
165+
public Task<WebSocket> AcceptAsync(WebSocketAcceptContext context)
166+
{
167+
context.DangerousEnableCompression = true;
168+
return originalFeature.AcceptAsync(context);
169+
}
118170
}
119171
}
120172
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions
3+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ConfigureWebsocketOptions.get -> System.Func<Microsoft.AspNetCore.Http.HttpContext!, Microsoft.AspNetCore.Http.WebSocketAcceptContext!>?
4+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ConfigureWebsocketOptions.set -> void
5+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ContentSecurityFrameAncestorPolicy.get -> string?
6+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ContentSecurityFrameAncestorPolicy.set -> void
7+
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ServerComponentsEndpointOptions() -> void
8+
static Microsoft.AspNetCore.Builder.ServerRazorComponentsEndpointConventionBuilderExtensions.AddInteractiveServerRenderMode(this Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder! builder, System.Action<Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions!>? callback = null) -> Microsoft.AspNetCore.Builder.RazorComponentsEndpointConventionBuilder!

src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Reflection;
55
using Microsoft.AspNetCore.E2ETesting;
6+
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.Hosting;
78

89
namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
@@ -17,6 +18,8 @@ public class AspNetSiteServerFixture : WebHostServerFixture
1718

1819
public BuildWebHost BuildWebHostMethod { get; set; }
1920

21+
public Action<IServiceProvider> UpdateHostServices { get; set; }
22+
2023
public GetContentRoot GetContentRootMethod { get; set; } = DefaultGetContentRoot;
2124

2225
public AspNetEnvironment Environment { get; set; } = AspNetEnvironment.Production;
@@ -40,12 +43,16 @@ protected override IHost CreateWebHost()
4043
host = E2ETestOptions.Instance.Sauce.HostName;
4144
}
4245

43-
return BuildWebHostMethod(new[]
46+
var result = BuildWebHostMethod(new[]
4447
{
4548
"--urls", $"http://{host}:0",
4649
"--contentroot", sampleSitePath,
4750
"--environment", Environment.ToString(),
4851
}.Concat(AdditionalArguments).ToArray());
52+
53+
UpdateHostServices?.Invoke(result.Services);
54+
55+
return result;
4956
}
5057

5158
private static string DefaultGetContentRoot(Assembly assembly)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.RegularExpressions;
5+
using Components.TestServer.RazorComponents;
6+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
7+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
8+
using Microsoft.AspNetCore.E2ETesting;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using Microsoft.VisualStudio.TestPlatform.Utilities;
11+
using OpenQA.Selenium;
12+
using TestServer;
13+
using Xunit.Abstractions;
14+
15+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;
16+
17+
public abstract partial class AllowedWebSocketCompressionTests(
18+
BrowserFixture browserFixture,
19+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
20+
ITestOutputHelper output)
21+
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
22+
{
23+
[Fact]
24+
public void EmbeddingServerAppInsideIframe_Works()
25+
{
26+
Navigate("/subdir/iframe");
27+
28+
var logs = Browser.GetBrowserLogs(LogLevel.Severe);
29+
30+
Assert.Empty(logs);
31+
32+
// Get the iframe element from the page, and inspect its contents for a p element with id inside-iframe
33+
var iframe = Browser.FindElement(By.TagName("iframe"));
34+
Browser.SwitchTo().Frame(iframe);
35+
Browser.Exists(By.Id("inside-iframe"));
36+
}
37+
}
38+
39+
public abstract partial class BlockedWebSocketCompressionTests(
40+
BrowserFixture browserFixture,
41+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
42+
ITestOutputHelper output)
43+
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
44+
{
45+
[Fact]
46+
public void EmbeddingServerAppInsideIframe_WithCompressionEnabled_Fails()
47+
{
48+
Navigate("/subdir/iframe");
49+
50+
var logs = Browser.GetBrowserLogs(LogLevel.Severe);
51+
52+
Assert.True(logs.Count > 0);
53+
54+
Assert.Matches(ParseErrorMessage(), logs[0].Message);
55+
}
56+
57+
[GeneratedRegex(@"security - Refused to frame 'http://\d+\.\d+\.\d+\.\d+:\d+/' because an ancestor violates the following Content Security Policy directive: ""frame-ancestors 'none'"".")]
58+
private static partial Regex ParseErrorMessage();
59+
}
60+
61+
public partial class DefaultConfigurationWebSocketCompressionTests(
62+
BrowserFixture browserFixture,
63+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
64+
ITestOutputHelper output)
65+
: AllowedWebSocketCompressionTests(browserFixture, serverFixture, output)
66+
{
67+
}
68+
69+
public partial class CustomConfigurationCallbackWebSocketCompressionTests : AllowedWebSocketCompressionTests
70+
{
71+
public CustomConfigurationCallbackWebSocketCompressionTests(
72+
BrowserFixture browserFixture,
73+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
74+
ITestOutputHelper output) : base(browserFixture, serverFixture, output)
75+
{
76+
serverFixture.UpdateHostServices = services =>
77+
{
78+
var configuration = services.GetService<WebSocketCompressionConfiguration>();
79+
configuration.ConnectionDispatcherOptions = context => new() { DangerousEnableCompression = true };
80+
};
81+
}
82+
}
83+
84+
public partial class CompressionDisabledWebSocketCompressionTests : AllowedWebSocketCompressionTests
85+
{
86+
public CompressionDisabledWebSocketCompressionTests(
87+
BrowserFixture browserFixture,
88+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
89+
ITestOutputHelper output) : base(
90+
browserFixture, serverFixture, output)
91+
{
92+
serverFixture.UpdateHostServices = services =>
93+
{
94+
var configuration = services.GetService<WebSocketCompressionConfiguration>();
95+
configuration.IsCompressionEnabled = false;
96+
configuration.ConnectionDispatcherOptions = null;
97+
};
98+
}
99+
}
100+
101+
public partial class NoneAncestorWebSocketCompressionTests : BlockedWebSocketCompressionTests
102+
{
103+
public NoneAncestorWebSocketCompressionTests(
104+
BrowserFixture browserFixture,
105+
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
106+
ITestOutputHelper output)
107+
: base(browserFixture, serverFixture, output)
108+
{
109+
serverFixture.UpdateHostServices = services =>
110+
{
111+
var configuration = services.GetService<WebSocketCompressionConfiguration>();
112+
configuration.CspPolicy = "'none'";
113+
};
114+
}
115+
}
116+

0 commit comments

Comments
 (0)