Skip to content
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

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

Merged
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,48 @@ 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);
};
ApplyEndpointFilters(builder);

endpoints.Add(builder.Build());
}

private static void ApplyEndpointFilters(RouteEndpointBuilder builder)
{
if (builder.FilterFactories.Count > 0)
{
EndpointFilterDelegate del = static async invocationContext =>
{
var httpContext = invocationContext.HttpContext;
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
await invoker.Render(httpContext);
return null;
};

var context = new EndpointFilterFactoryContext
{
MethodInfo = typeof(IRazorComponentEndpointInvoker).GetMethod(nameof(IRazorComponentEndpointInvoker.Render))!,
ApplicationServices = builder.ApplicationServices,
};

for (var i = builder.FilterFactories.Count - 1; i >= 0; i--)
{
var filterFactory = builder.FilterFactories[i];
del = filterFactory(context, del);
}

builder.RequestDelegate = async context =>
{
var invocationContext = EndpointFilterInvocationContext.Create(context);
await del(invocationContext);
};
}
else
{
builder.RequestDelegate = static httpContext =>
{
var invoker = httpContext.RequestServices.GetRequiredService<IRazorComponentEndpointInvoker>();
return invoker.Render(httpContext);
};
}
}
}
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,45 @@
// 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;
using Microsoft.AspNetCore.Http.Connections;

namespace Microsoft.AspNetCore.Components.Server;

/// <summary>
/// Options to configure interactive Server components.
/// </summary>
public class ServerComponentsEndpointOptions
javiercn marked this conversation as resolved.
Show resolved Hide resolved
{
/// <summary>
/// Gets or sets the <c>frame-ancestors</c> <c>Content-Security-Policy</c> to set in the
/// <see cref="HttpResponse"/> when <see cref="ConfigureConnectionOptions" /> 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 callback to configure the underlying <see cref="HttpConnectionDispatcherOptions"/>.
/// 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 Action<HttpConnectionDispatcherOptions>? ConfigureConnectionOptions { get; set; } =
options => options.WebSockets.WebSocketAcceptContextFactory = EnableCompressionDefaults;

private static WebSocketAcceptContext EnableCompressionDefaults(HttpContext context) =>
new() { DangerousEnableCompression = true };
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// The .NET Foundation licenses this file to you under the MIT license.

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

namespace Microsoft.AspNetCore.Builder;

Expand All @@ -17,7 +20,38 @@ 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.ConfigureConnectionOptions is not null && options.ContentSecurityFrameAncestorPolicy != null)
{
builder.AddEndpointFilter(new RequireCspFilter(options.ContentSecurityFrameAncestorPolicy));
javiercn marked this conversation as resolved.
Show resolved Hide resolved
}

return builder;
}

private sealed class RequireCspFilter(string policy) : IEndpointFilter
{
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
context.HttpContext.Response.Headers.Add("Content-Security-Policy", $"frame-ancestors {policy}");
javiercn marked this conversation as resolved.
Show resolved Hide resolved
return next(context);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ public override IEnumerable<RouteEndpointBuilder> GetEndpointBuilders(
}

var endpointRouteBuilder = new EndpointRouteBuilder(Services, applicationBuilder);
endpointRouteBuilder.MapBlazorHub();
if (renderMode is InternalServerRenderMode { Options.ConfigureConnectionOptions: { } configureConnection })
{
endpointRouteBuilder.MapBlazorHub("/_blazor", configureConnection);
}
else
{
endpointRouteBuilder.MapBlazorHub();
}

return endpointRouteBuilder.GetEndpoints();
}
Expand Down
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.ConfigureConnectionOptions.get -> System.Action<Microsoft.AspNetCore.Http.Connections.HttpConnectionDispatcherOptions!>?
Microsoft.AspNetCore.Components.Server.ServerComponentsEndpointOptions.ConfigureConnectionOptions.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,117 @@
// 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 = options =>
options.WebSockets.WebSocketAcceptContextFactory = context =>
new Http.WebSocketAcceptContext { 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;
};
}
}

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'";
};
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapControllers();
endpoints.MapRazorPages();
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
_configureMode(endpoints);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapRazorPages();
endpoints.MapFallbackToPage("/DeferredComponentContentHost");
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
endpoints.MapFallbackToPage("/_ServerHost");
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
endpoints.MapFallbackToPage("/_ServerHost");
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapRazorPages();
endpoints.MapFallbackToPage("/LockedNavigationHost");
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
});
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapRazorPages();
endpoints.MapFallbackToPage("/MultipleComponents");
endpoints.MapBlazorHub();
endpoints.MapBlazorHub(options => options.WebSockets.WebSocketAcceptContextFactory = context => new WebSocketAcceptContext { DangerousEnableCompression = true });
});
});
}
Expand Down
Loading
Loading