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

Add CORS to OTLP HTTP endpoint #5177

Merged
merged 5 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 expect 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 @@ -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<EndpointInfo> endPointAccessor)
{
// Check that the specified dynamic port of 0 is overridden with the actual port number.
Expand Down