diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 9847899035..22f1b59a9b 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -228,6 +228,7 @@ public DashboardWebApplication(Action? configureBuilder = _app.UseAuthorization(); + _app.UseMiddleware(); _app.UseAntiforgery(); _app.MapRazorComponents().AddInteractiveServerRenderMode(); diff --git a/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs b/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs new file mode 100644 index 0000000000..fe7c865f4d --- /dev/null +++ b/src/Aspire.Dashboard/Model/BrowserSecurityHeadersMiddleware.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Authentication.OtlpConnection; + +namespace Aspire.Dashboard.Model; + +/// +/// Middleware adds headers related to browser security that aren't built into ASP.NET Core: +/// - Content-Security-Policy +/// - Referrer-Policy +/// - X-Content-Type-Options +/// +internal sealed class BrowserSecurityHeadersMiddleware +{ + private readonly RequestDelegate _next; + private readonly string _cspContent; + + public BrowserSecurityHeadersMiddleware(RequestDelegate next, IHostEnvironment environment) + { + _next = next; + + // Based on Blazor documentation recommendations: + // https://learn.microsoft.com/aspnet/core/blazor/security/content-security-policy#server-side-blazor-apps + // Changes: + // - style-src adds inline styles as they're used extensively by Blazor FluentUI. + // - frame-src none added to prevent nesting in iframe. + var content = "base-uri 'self'; " + + "img-src data: https:; " + + "object-src 'none'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "frame-src 'none';"; + + // default-src limits where content can fetched from. + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src + // This value stops BrowserLink and automatic hot reload from working in development. + // Add this value for all non-development environments. + if (!environment.IsDevelopment()) + { + content += " default-src 'self';"; + } + + _cspContent = content; + } + + public Task InvokeAsync(HttpContext context) + { + // Don't set browser security headers on OTLP requests. + if (context.Features.Get() == null) + { + context.Response.Headers.ContentSecurityPolicy = _cspContent; + + // Recommended best practice value: https://web.dev/articles/referrer-best-practices + context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; + + context.Response.Headers.XContentTypeOptions = "nosniff"; + } + + return _next(context); + } +} diff --git a/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs b/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs new file mode 100644 index 0000000000..c36dee0226 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/BrowserSecurityHeadersMiddlewareTests.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard.Authentication.OtlpConnection; +using Aspire.Dashboard.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Aspire.Dashboard.Tests; + +public class BrowserSecurityHeadersMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_Development_AllowExternalFetch() + { + // Arrange + var middleware = CreateMiddleware(environmentName: "Development"); + var httpContext = new DefaultHttpContext(); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.NotEqual(StringValues.Empty, httpContext.Response.Headers.ContentSecurityPolicy); + Assert.DoesNotContain("default-src", httpContext.Response.Headers.ContentSecurityPolicy.ToString()); + } + + [Fact] + public async Task InvokeAsync_Production_DenyExternalFetch() + { + // Arrange + var middleware = CreateMiddleware(environmentName: "Production"); + var httpContext = new DefaultHttpContext(); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.NotEqual(StringValues.Empty, httpContext.Response.Headers.ContentSecurityPolicy); + Assert.Contains("default-src", httpContext.Response.Headers.ContentSecurityPolicy.ToString()); + } + + [Fact] + public async Task InvokeAsync_Otlp_NotAdded() + { + // Arrange + var middleware = CreateMiddleware(environmentName: "Production"); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new OtlpConnectionFeature()); + + // Act + await middleware.InvokeAsync(httpContext); + + // Assert + Assert.Equal(StringValues.Empty, httpContext.Response.Headers.ContentSecurityPolicy); + } + + private sealed class OtlpConnectionFeature : IOtlpConnectionFeature + { + } + + private static BrowserSecurityHeadersMiddleware CreateMiddleware(string environmentName) => + new BrowserSecurityHeadersMiddleware(c => Task.CompletedTask, new TestHostEnvironment { EnvironmentName = environmentName }); + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string ApplicationName { get; set; } = "ApplicationName"; + public IFileProvider ContentRootFileProvider { get; set; } = default!; + public string ContentRootPath { get; set; } = "ContentRootPath"; + public string EnvironmentName { get; set; } = "Development"; + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs index 00b4d23285..757de8e470 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpServiceTests.cs @@ -35,10 +35,13 @@ public async Task CallService_OtlpEndPoint_Success() var client = new LogsService.LogsServiceClient(channel); // Act - var response = await client.ExportAsync(new ExportLogsServiceRequest()); + var response = client.ExportAsync(new ExportLogsServiceRequest()); + var message = await response.ResponseAsync; + var headers = await response.ResponseHeadersAsync; // Assert - Assert.Equal(0, response.PartialSuccess.RejectedLogRecords); + Assert.Null(headers.GetValue("content-security-policy")); + Assert.Equal(0, message.PartialSuccess.RejectedLogRecords); } [Fact] diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 8f44c685f1..c32e289cdf 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; using OpenTelemetry.Proto.Collector.Logs.V1; using Xunit; using Xunit.Abstractions; @@ -358,6 +359,7 @@ public async Task EndPointAccessors_AppStarted_BrowserGet_Success() // Assert response.EnsureSuccessStatusCode(); + Assert.NotEmpty(response.Headers.GetValues(HeaderNames.ContentSecurityPolicy).Single()); } private static void AssertDynamicIPEndpoint(Func endPointAccessor)