Skip to content

Commit

Permalink
Add CORS to OTLP HTTP endpoint (#5177)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Costello <[email protected]>
  • Loading branch information
JamesNK and martincostello authored Aug 7, 2024
1 parent 797fe38 commit 10ee979
Show file tree
Hide file tree
Showing 6 changed files with 209 additions and 8 deletions.
14 changes: 14 additions & 0 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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;

Expand All @@ -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
{
Expand Down
18 changes: 11 additions & 7 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IDashboardClient, DashboardClient>();
Expand Down Expand Up @@ -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<ValidateTokenMiddleware>();

// Configure the HTTP request pipeline.
Expand Down Expand Up @@ -301,11 +309,7 @@ public DashboardWebApplication(
_app.MapRazorComponents<App>().AddInteractiveServerRenderMode();

// OTLP HTTP services.
var httpEndpoint = dashboardOptions.Otlp.GetHttpEndpointUri();
if (httpEndpoint != null)
{
_app.MapHttpOtlpApi();
}
_app.MapHttpOtlpApi(dashboardOptions.Otlp);

// OTLP gRPC services.
_app.MapGrpcService<OtlpGrpcMetricsService>();
Expand Down Expand Up @@ -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);
}

Expand Down
31 changes: 30 additions & 1 deletion src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ExportLogsServiceRequest> request, OtlpLogsService service) =>
{
if (request.Message == null)
Expand Down
2 changes: 2 additions & 0 deletions src/Shared/DashboardConfigNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
136 changes: 136 additions & 0 deletions tests/Aspire.Dashboard.Tests/Integration/OtlpCorsHttpServiceTests.cs
Original file line number Diff line number Diff line change
@@ -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());
}
}
16 changes: 16 additions & 0 deletions tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,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<EndpointInfo> endPointAccessor)
{
// Check that the specified dynamic port of 0 is overridden with the actual port number.
Expand Down

0 comments on commit 10ee979

Please sign in to comment.