diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index edd8e64f44..2bc59c6213 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -94,6 +94,8 @@ public byte[] GetPrimaryApiKeyBytes() public byte[]? GetSecondaryApiKeyBytes() => _secondaryApiKeyBytes; + public OtlpCors Cors { get; set; } = new(); + internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) { if (string.IsNullOrEmpty(GrpcEndpointUrl) && string.IsNullOrEmpty(HttpEndpointUrl)) @@ -114,6 +116,12 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) return false; } + if (string.IsNullOrEmpty(HttpEndpointUrl) && !string.IsNullOrEmpty(Cors.AllowedOrigins)) + { + errorMessage = $"CORS configured without an OTLP HTTP endpoint. Either remove CORS configuration or specify a {DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName} value."; + return false; + } + _primaryApiKeyBytes = PrimaryApiKey != null ? Encoding.UTF8.GetBytes(PrimaryApiKey) : null; _secondaryApiKeyBytes = SecondaryApiKey != null ? Encoding.UTF8.GetBytes(SecondaryApiKey) : null; @@ -122,6 +130,12 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage) } } +public sealed class OtlpCors +{ + public string? AllowedOrigins { get; set; } + public string? AllowedHeaders { get; set; } +} + // Don't set values after validating/parsing options. public sealed class FrontendOptions { diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index d1c563ac2a..e7f5027ca7 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -148,6 +148,7 @@ public DashboardWebApplication( // See https://learn.microsoft.com/aspnet/core/performance/response-compression#compression-with-https for more information options.MimeTypes = ["text/javascript", "application/javascript", "text/css", "image/svg+xml"]; }); + builder.Services.AddCors(); // Data from the server. builder.Services.AddScoped(); @@ -257,6 +258,13 @@ public DashboardWebApplication( await next(context).ConfigureAwait(false); }); + if (!string.IsNullOrEmpty(dashboardOptions.Otlp.Cors.AllowedOrigins)) + { + // Only add CORS middleware when there is CORS configuration. + // Because there isn't a default policy, CORS isn't enabled except on certain endpoints, e.g. OTLP HTTP endpoints. + _app.UseCors(); + } + _app.UseMiddleware(); // Configure the HTTP request pipeline. @@ -301,11 +309,7 @@ public DashboardWebApplication( _app.MapRazorComponents().AddInteractiveServerRenderMode(); // OTLP HTTP services. - var httpEndpoint = dashboardOptions.Otlp.GetHttpEndpointUri(); - if (httpEndpoint != null) - { - _app.MapHttpOtlpApi(); - } + _app.MapHttpOtlpApi(dashboardOptions.Otlp); // OTLP gRPC services. _app.MapGrpcService(); @@ -697,13 +701,13 @@ public int Run() public Task StartAsync(CancellationToken cancellationToken = default) { - Debug.Assert(_validationFailures.Count == 0); + Debug.Assert(_validationFailures.Count == 0, "Validation failures: " + Environment.NewLine + string.Join(Environment.NewLine, _validationFailures)); return _app.StartAsync(cancellationToken); } public Task StopAsync(CancellationToken cancellationToken = default) { - Debug.Assert(_validationFailures.Count == 0); + Debug.Assert(_validationFailures.Count == 0, "Validation failures: " + Environment.NewLine + string.Join(Environment.NewLine, _validationFailures)); return _app.StopAsync(cancellationToken); } diff --git a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs index 74425a6024..528c7aa5dc 100644 --- a/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs @@ -6,6 +6,7 @@ using System.Net.Http.Headers; using System.Reflection; using Aspire.Dashboard.Authentication; +using Aspire.Dashboard.Configuration; using Google.Protobuf; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; @@ -19,13 +20,41 @@ public static class OtlpHttpEndpointsBuilder { public const string ProtobufContentType = "application/x-protobuf"; public const string JsonContentType = "application/json"; + // By default, allow headers in the implicit safelist and X-Requested-With. This matches OTLP collector CORS behavior. + // Implicit safelist: https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header + // OTLP collector: https://github.com/open-telemetry/opentelemetry-collector/blob/685625abb4703cb2e45a397f008127bbe2ba4c0e/config/confighttp/README.md#server-configuration + public static readonly string[] DefaultAllowedHeaders = ["X-Requested-With"]; - public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints) + public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints, OtlpOptions options) { + var httpEndpoint = options.GetHttpEndpointUri(); + if (httpEndpoint == null) + { + // Don't map OTLP HTTP route endpoints if there isn't a Kestrel endpoint to access them with. + return; + } + var group = endpoints .MapGroup("/v1") .AddOtlpHttpMetadata(); + if (!string.IsNullOrEmpty(options.Cors.AllowedOrigins)) + { + group = group.RequireCors(builder => + { + builder.WithOrigins(options.Cors.AllowedOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + builder.SetIsOriginAllowedToAllowWildcardSubdomains(); + + var allowedHeaders = !string.IsNullOrEmpty(options.Cors.AllowedHeaders) + ? options.Cors.AllowedHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : DefaultAllowedHeaders; + builder.WithHeaders(allowedHeaders); + + // Hardcode to allow only POST methods. OTLP is always sent in POST request bodies. + builder.WithMethods(HttpMethods.Post); + }); + } + group.MapPost("logs", static (MessageBindable request, OtlpLogsService service) => { if (request.Message == null) diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 3cd4bb8c41..a426dd6a04 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -15,6 +15,8 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardOtlpAuthModeName = new("Dashboard:Otlp:AuthMode", "DASHBOARD__OTLP__AUTHMODE"); public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY"); public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY"); + public static readonly ConfigName DashboardOtlpCorsAllowedOriginsKeyName = new("Dashboard:Otlp:Cors:AllowedOrigins", "DASHBOARD__OTLP__CORS__ALLOWEDORIGINS"); + public static readonly ConfigName DashboardOtlpCorsAllowedHeadersKeyName = new("Dashboard:Otlp:Cors:AllowedHeaders", "DASHBOARD__OTLP__CORS__ALLOWEDHEADERS"); public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE"); public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN"); public static readonly ConfigName DashboardFrontendMaxConsoleLogCountName = new("Dashboard:Frontend:MaxConsoleLogCount", "DASHBOARD__FRONTEND__MAXCONSOLELOGCOUNT"); diff --git a/tests/Aspire.Dashboard.Tests/Integration/OtlpCorsHttpServiceTests.cs b/tests/Aspire.Dashboard.Tests/Integration/OtlpCorsHttpServiceTests.cs new file mode 100644 index 0000000000..8f099eedf0 --- /dev/null +++ b/tests/Aspire.Dashboard.Tests/Integration/OtlpCorsHttpServiceTests.cs @@ -0,0 +1,136 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net; +using Aspire.Hosting; +using Xunit; +using Xunit.Abstractions; + +namespace Aspire.Dashboard.Tests.Integration; + +public class OtlpCorsHttpServiceTests +{ + private readonly ITestOutputHelper _testOutputHelper; + + public OtlpCorsHttpServiceTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + [Fact] + public async Task ReceivePreflight_OtlpHttpEndPoint_NoCorsConfiguration_NotFound() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper); + await app.StartAsync(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}"); + + var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type"); + preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000"); + + // Act + var responseMessage = await httpClient.SendAsync(preflightRequest); + + // Assert + Assert.Equal(HttpStatusCode.NotFound, responseMessage.StatusCode); + } + + [Fact] + public async Task ReceivePreflight_OtlpHttpEndPoint_ValidCorsOrigin_Success() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "http://localhost:8000, http://localhost:8001"; + }); + await app.StartAsync(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}"); + + // Act 1 + var preflightRequest1 = new HttpRequestMessage(HttpMethod.Options, "/v1/logs"); + preflightRequest1.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST"); + preflightRequest1.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type"); + preflightRequest1.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000"); + + var responseMessage1 = await httpClient.SendAsync(preflightRequest1); + + // Assert 1 + Assert.Equal(HttpStatusCode.NoContent, responseMessage1.StatusCode); + Assert.Equal("http://localhost:8000", responseMessage1.Headers.GetValues("Access-Control-Allow-Origin").Single()); + Assert.Equal("POST", responseMessage1.Headers.GetValues("Access-Control-Allow-Methods").Single()); + Assert.Equal("X-Requested-With", responseMessage1.Headers.GetValues("Access-Control-Allow-Headers").Single()); + + // Act 2 + var preflightRequest2 = new HttpRequestMessage(HttpMethod.Options, "/v1/logs"); + preflightRequest2.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST"); + preflightRequest2.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type"); + preflightRequest2.Headers.TryAddWithoutValidation("Origin", "http://localhost:8001"); + + var responseMessage2 = await httpClient.SendAsync(preflightRequest2); + + // Assert 2 + Assert.Equal(HttpStatusCode.NoContent, responseMessage2.StatusCode); + Assert.Equal("http://localhost:8001", responseMessage2.Headers.GetValues("Access-Control-Allow-Origin").Single()); + Assert.Equal("POST", responseMessage2.Headers.GetValues("Access-Control-Allow-Methods").Single()); + Assert.Equal("X-Requested-With", responseMessage2.Headers.GetValues("Access-Control-Allow-Headers").Single()); + } + + [Fact] + public async Task ReceivePreflight_OtlpHttpEndPoint_InvalidCorsOrigin_NoCorsHeadersReturned() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "http://localhost:8000"; + }); + await app.StartAsync(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}"); + + var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type"); + preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8001"); + + // Act + var responseMessage = await httpClient.SendAsync(preflightRequest); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, responseMessage.StatusCode); + Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Origin")); + Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Methods")); + Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Headers")); + } + + [Fact] + public async Task ReceivePreflight_OtlpHttpEndPoint_AnyOrigin_Success() + { + // Arrange + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "*"; + config[DashboardConfigNames.DashboardOtlpCorsAllowedHeadersKeyName.ConfigKey] = "*"; + }); + await app.StartAsync(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}"); + + var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST"); + preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type"); + preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000"); + + // Act + var responseMessage = await httpClient.SendAsync(preflightRequest); + + // Assert + Assert.Equal(HttpStatusCode.NoContent, responseMessage.StatusCode); + Assert.Equal("*", responseMessage.Headers.GetValues("Access-Control-Allow-Origin").Single()); + Assert.Equal("POST", responseMessage.Headers.GetValues("Access-Control-Allow-Methods").Single()); + Assert.Equal("x-requested-with,x-custom,Content-Type", responseMessage.Headers.GetValues("Access-Control-Allow-Headers").Single()); + } +} diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index 1ee6bd88d2..3df61099ef 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -436,6 +436,22 @@ public async Task EndPointAccessors_AppStarted_BrowserGet_Success() Assert.NotEmpty(response.Headers.GetValues(HeaderNames.ContentSecurityPolicy).Single()); } + [Fact] + public async Task Configuration_CorsNoOtlpHttpEndpoint_Error() + { + // Arrange & Act + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: data => + { + data.Remove(DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey); + data[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "https://localhost:666"; + }); + + // Assert + Assert.Collection(app.ValidationFailures, + s => Assert.Contains(DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey, s)); + } + private static void AssertDynamicIPEndpoint(Func endPointAccessor) { // Check that the specified dynamic port of 0 is overridden with the actual port number.