Skip to content

[Blazor] Enable websocket compression for Blazor Server and Interactive Server components in Blazor web #53389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ internal void AddEndpoints(
builder.Metadata.Add(new RootComponentMetadata(rootComponent));
builder.Metadata.Add(configuredRenderModesMetadata);

builder.RequestDelegate = static httpContext =>
{
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
return invoker.Render(httpContext);
};

foreach (var convention in conventions)
{
convention(builder);
Expand All @@ -67,12 +73,6 @@ internal void AddEndpoints(
// The display name is for debug purposes by endpoint routing.
builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})";

builder.RequestDelegate = httpContext =>
{
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
return invoker.Render(httpContext);
};

endpoints.Add(builder.Build());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private static IEndpointConventionBuilder GetBlazorEndpoint(IEndpointRouteBuilde
.WithDisplayName("Blazor static files");

blazorEndpoint.Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);

#if DEBUG
// We only need to serve the sourcemap when working on the framework, not in the distributed packages
endpoints.Map("/_framework/blazor.server.js.map", app.Build())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Web;

namespace Microsoft.AspNetCore.Builder;
namespace Microsoft.AspNetCore.Components.Server;

internal class InternalServerRenderMode : InteractiveServerRenderMode
internal class InternalServerRenderMode(ServerComponentsEndpointOptions options) : InteractiveServerRenderMode
{
public ServerComponentsEndpointOptions? Options { get; } = options;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Components.Server;

/// <summary>
/// Options to configure interactive Server components.
/// </summary>
public class ServerComponentsEndpointOptions
{
/// <summary>
/// Gets or sets the <c>frame-ancestors</c> <c>Content-Security-Policy</c> to set in the
/// <see cref="HttpResponse"/> when <see cref="ConfigureWebsocketOptions" /> is set.
/// </summary>
/// <remarks>
/// <para>Setting this value to <see langword="null" /> will prevent the policy from being
/// automatically applied, which might make the app vulnerable. Care must be taken to apply
/// a policy in this case whenever the first document is rendered.
/// </para>
/// <para>
/// A content security policy provides defense against security threats that can occur if
/// the app uses compression and can be embedded in other origins. When compression is enabled,
/// embedding the app inside an <c>iframe</c> from other origins is prohibited.
/// </para>
/// <para>
/// For more details see the security recommendations for Interactive Server Components in
/// the official documentation.
/// </para>
/// </remarks>
public string? ContentSecurityFrameAncestorPolicy { get; set; } = "'self'";

/// <summary>
/// Gets or sets a function to configure the <see cref="WebSocketAcceptContext"/> for the websocket connections
/// used by the server components.
/// By default, a policy that enables compression and sets a Content Security Policy for the frame ancestors
/// defined in <see cref="ContentSecurityFrameAncestorPolicy"/> will be applied.
/// </summary>
public Func<HttpContext, WebSocketAcceptContext>? ConfigureWebsocketOptions { get; set; } = EnableCompressionDefaults;

private static WebSocketAcceptContext EnableCompressionDefaults(HttpContext context) =>
new() { DangerousEnableCompression = true };
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.Endpoints;
using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR;

namespace Microsoft.AspNetCore.Builder;

Expand All @@ -17,7 +20,44 @@ public static class ServerRazorComponentsEndpointConventionBuilderExtensions
/// <returns>The <see cref="RazorComponentsEndpointConventionBuilder"/>.</returns>
public static RazorComponentsEndpointConventionBuilder AddInteractiveServerRenderMode(this RazorComponentsEndpointConventionBuilder builder)
{
ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new InternalServerRenderMode());
return AddInteractiveServerRenderMode(builder, null);
}

/// <summary>
/// Maps the Blazor <see cref="Hub" /> to the default path.
/// </summary>
/// <param name="builder">The <see cref="RazorComponentsEndpointConventionBuilder"/>.</param>
/// <param name="callback">A callback to configure server endpoint options.</param>
/// <returns>The <see cref="ComponentEndpointConventionBuilder"/>.</returns>
public static RazorComponentsEndpointConventionBuilder AddInteractiveServerRenderMode(
this RazorComponentsEndpointConventionBuilder builder,
Action<ServerComponentsEndpointOptions>? callback = null)
{
var options = new ServerComponentsEndpointOptions();
callback?.Invoke(options);

ComponentEndpointConventionBuilderHelper.AddRenderMode(builder, new InternalServerRenderMode(options));

if (options.ConfigureWebsocketOptions is not null && options.ContentSecurityFrameAncestorPolicy != null)
{
builder.Add(b =>
{
for (var i = 0; i < b.Metadata.Count; i++)
{
var metadata = b.Metadata[i];
if (metadata is ComponentTypeMetadata)
{
var original = b.RequestDelegate;
b.RequestDelegate = async context =>
{
context.Response.Headers.Add("Content-Security-Policy", $"frame-ancestors {options.ContentSecurityFrameAncestorPolicy}");
await original(context);
};
}
}
});
}

return builder;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Endpoints.Infrastructure;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection.Extensions;

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

return Array.Empty<RouteEndpointBuilder>();
return [];
}

var endpointRouteBuilder = new EndpointRouteBuilder(Services, applicationBuilder);
endpointRouteBuilder.MapBlazorHub();
var hub = endpointRouteBuilder.MapBlazorHub("/_blazor");

if (renderMode is InternalServerRenderMode { Options.ConfigureWebsocketOptions: { } configureConnection })
{
hub.Finally(c =>
{
for (var i = 0; i < c.Metadata.Count; i++)
{
var metadata = c.Metadata[i];
if (metadata is NegotiateMetadata)
{
return;
}

if (metadata is HubMetadata)
{
var originalDelegate = c.RequestDelegate;
var builder = endpointRouteBuilder.CreateApplicationBuilder();
builder.UseWebSockets();
builder.Use(static (ctx, nxt) =>
{
if (ctx.WebSockets.IsWebSocketRequest)
{
var currentFeature = ctx.Features.Get<IHttpWebSocketFeature>();

ctx.Features.Set<IHttpWebSocketFeature>(new ServerComponentsSocketFeature(currentFeature!));
}
return nxt(ctx);
});
builder.Run(originalDelegate);
c.RequestDelegate = builder.Build();
return;
}
}
});
}

return endpointRouteBuilder.GetEndpoints();
}
Expand Down Expand Up @@ -115,6 +155,18 @@ internal IEnumerable<RouteEndpointBuilder> GetEndpoints()
}
}
}

}

private sealed class ServerComponentsSocketFeature(IHttpWebSocketFeature originalFeature) : IHttpWebSocketFeature
{
public bool IsWebSocketRequest => originalFeature.IsWebSocketRequest;

public Task<WebSocket> AcceptAsync(WebSocketAcceptContext context)
{
context.DangerousEnableCompression = true;
return originalFeature.AcceptAsync(context);
}
}
}
}
7 changes: 7 additions & 0 deletions src/Components/Server/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
#nullable enable
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ConfigureWebsocketOptions.get -> System.Func<Microsoft.AspNetCore.Http.HttpContext!, Microsoft.AspNetCore.Http.WebSocketAcceptContext!>?
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ConfigureWebsocketOptions.set -> void
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ContentSecurityFrameAncestorPolicy.get -> string?
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ContentSecurityFrameAncestorPolicy.set -> void
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ServerComponentsEndpointOptions() -> void
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!
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Reflection;
using Microsoft.AspNetCore.E2ETesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

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

public BuildWebHost BuildWebHostMethod { get; set; }

public Action<IServiceProvider> UpdateHostServices { get; set; }

public GetContentRoot GetContentRootMethod { get; set; } = DefaultGetContentRoot;

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

return BuildWebHostMethod(new[]
var result = BuildWebHostMethod(new[]
{
"--urls", $"http://{host}:0",
"--contentroot", sampleSitePath,
"--environment", Environment.ToString(),
}.Concat(AdditionalArguments).ToArray());

UpdateHostServices?.Invoke(result.Services);

return result;
}

private static string DefaultGetContentRoot(Assembly assembly)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.RegularExpressions;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestPlatform.Utilities;
using OpenQA.Selenium;
using TestServer;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;

public abstract partial class AllowedWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
{
[Fact]
public void EmbeddingServerAppInsideIframe_Works()
{
Navigate("/subdir/iframe");

var logs = Browser.GetBrowserLogs(LogLevel.Severe);

Assert.Empty(logs);

// Get the iframe element from the page, and inspect its contents for a p element with id inside-iframe
var iframe = Browser.FindElement(By.TagName("iframe"));
Browser.SwitchTo().Frame(iframe);
Browser.Exists(By.Id("inside-iframe"));
}
}

public abstract partial class BlockedWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
{
[Fact]
public void EmbeddingServerAppInsideIframe_WithCompressionEnabled_Fails()
{
Navigate("/subdir/iframe");

var logs = Browser.GetBrowserLogs(LogLevel.Severe);

Assert.True(logs.Count > 0);

Assert.Matches(ParseErrorMessage(), logs[0].Message);
}

[GeneratedRegex(@"security - Refused to frame 'http://\d+\.\d+\.\d+\.\d+:\d+/' because an ancestor violates the following Content Security Policy directive: ""frame-ancestors 'none'"".")]
private static partial Regex ParseErrorMessage();
}

public partial class DefaultConfigurationWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: AllowedWebSocketCompressionTests(browserFixture, serverFixture, output)
{
}

public partial class CustomConfigurationCallbackWebSocketCompressionTests : AllowedWebSocketCompressionTests
{
public CustomConfigurationCallbackWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output) : base(browserFixture, serverFixture, output)
{
serverFixture.UpdateHostServices = services =>
{
var configuration = services.GetService<WebSocketCompressionConfiguration>();
configuration.ConnectionDispatcherOptions = context => new() { DangerousEnableCompression = true };
};
}
}

public partial class CompressionDisabledWebSocketCompressionTests : AllowedWebSocketCompressionTests
{
public CompressionDisabledWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output) : base(
browserFixture, serverFixture, output)
{
serverFixture.UpdateHostServices = services =>
{
var configuration = services.GetService<WebSocketCompressionConfiguration>();
configuration.IsCompressionEnabled = false;
configuration.ConnectionDispatcherOptions = null;
};
}
}

public partial class NoneAncestorWebSocketCompressionTests : BlockedWebSocketCompressionTests
{
public NoneAncestorWebSocketCompressionTests(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
serverFixture.UpdateHostServices = services =>
{
var configuration = services.GetService<WebSocketCompressionConfiguration>();
configuration.CspPolicy = "'none'";
};
}
}

Loading