Skip to content

Commit 10ee979

Browse files
Add CORS to OTLP HTTP endpoint (#5177)
Co-authored-by: Martin Costello <martin@martincostello.com>
1 parent 797fe38 commit 10ee979

File tree

6 files changed

+209
-8
lines changed

6 files changed

+209
-8
lines changed

src/Aspire.Dashboard/Configuration/DashboardOptions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ public byte[] GetPrimaryApiKeyBytes()
9494

9595
public byte[]? GetSecondaryApiKeyBytes() => _secondaryApiKeyBytes;
9696

97+
public OtlpCors Cors { get; set; } = new();
98+
9799
internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
98100
{
99101
if (string.IsNullOrEmpty(GrpcEndpointUrl) && string.IsNullOrEmpty(HttpEndpointUrl))
@@ -114,6 +116,12 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
114116
return false;
115117
}
116118

119+
if (string.IsNullOrEmpty(HttpEndpointUrl) && !string.IsNullOrEmpty(Cors.AllowedOrigins))
120+
{
121+
errorMessage = $"CORS configured without an OTLP HTTP endpoint. Either remove CORS configuration or specify a {DashboardConfigNames.DashboardOtlpHttpUrlName.EnvVarName} value.";
122+
return false;
123+
}
124+
117125
_primaryApiKeyBytes = PrimaryApiKey != null ? Encoding.UTF8.GetBytes(PrimaryApiKey) : null;
118126
_secondaryApiKeyBytes = SecondaryApiKey != null ? Encoding.UTF8.GetBytes(SecondaryApiKey) : null;
119127

@@ -122,6 +130,12 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
122130
}
123131
}
124132

133+
public sealed class OtlpCors
134+
{
135+
public string? AllowedOrigins { get; set; }
136+
public string? AllowedHeaders { get; set; }
137+
}
138+
125139
// Don't set values after validating/parsing options.
126140
public sealed class FrontendOptions
127141
{

src/Aspire.Dashboard/DashboardWebApplication.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ public DashboardWebApplication(
148148
// See https://learn.microsoft.com/aspnet/core/performance/response-compression#compression-with-https for more information
149149
options.MimeTypes = ["text/javascript", "application/javascript", "text/css", "image/svg+xml"];
150150
});
151+
builder.Services.AddCors();
151152

152153
// Data from the server.
153154
builder.Services.AddScoped<IDashboardClient, DashboardClient>();
@@ -257,6 +258,13 @@ public DashboardWebApplication(
257258
await next(context).ConfigureAwait(false);
258259
});
259260

261+
if (!string.IsNullOrEmpty(dashboardOptions.Otlp.Cors.AllowedOrigins))
262+
{
263+
// Only add CORS middleware when there is CORS configuration.
264+
// Because there isn't a default policy, CORS isn't enabled except on certain endpoints, e.g. OTLP HTTP endpoints.
265+
_app.UseCors();
266+
}
267+
260268
_app.UseMiddleware<ValidateTokenMiddleware>();
261269

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

303311
// OTLP HTTP services.
304-
var httpEndpoint = dashboardOptions.Otlp.GetHttpEndpointUri();
305-
if (httpEndpoint != null)
306-
{
307-
_app.MapHttpOtlpApi();
308-
}
312+
_app.MapHttpOtlpApi(dashboardOptions.Otlp);
309313

310314
// OTLP gRPC services.
311315
_app.MapGrpcService<OtlpGrpcMetricsService>();
@@ -697,13 +701,13 @@ public int Run()
697701

698702
public Task StartAsync(CancellationToken cancellationToken = default)
699703
{
700-
Debug.Assert(_validationFailures.Count == 0);
704+
Debug.Assert(_validationFailures.Count == 0, "Validation failures: " + Environment.NewLine + string.Join(Environment.NewLine, _validationFailures));
701705
return _app.StartAsync(cancellationToken);
702706
}
703707

704708
public Task StopAsync(CancellationToken cancellationToken = default)
705709
{
706-
Debug.Assert(_validationFailures.Count == 0);
710+
Debug.Assert(_validationFailures.Count == 0, "Validation failures: " + Environment.NewLine + string.Join(Environment.NewLine, _validationFailures));
707711
return _app.StopAsync(cancellationToken);
708712
}
709713

src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Net.Http.Headers;
77
using System.Reflection;
88
using Aspire.Dashboard.Authentication;
9+
using Aspire.Dashboard.Configuration;
910
using Google.Protobuf;
1011
using Microsoft.AspNetCore.Mvc;
1112
using Microsoft.Extensions.Primitives;
@@ -19,13 +20,41 @@ public static class OtlpHttpEndpointsBuilder
1920
{
2021
public const string ProtobufContentType = "application/x-protobuf";
2122
public const string JsonContentType = "application/json";
23+
// By default, allow headers in the implicit safelist and X-Requested-With. This matches OTLP collector CORS behavior.
24+
// Implicit safelist: https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header
25+
// OTLP collector: https://github.com/open-telemetry/opentelemetry-collector/blob/685625abb4703cb2e45a397f008127bbe2ba4c0e/config/confighttp/README.md#server-configuration
26+
public static readonly string[] DefaultAllowedHeaders = ["X-Requested-With"];
2227

23-
public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints)
28+
public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints, OtlpOptions options)
2429
{
30+
var httpEndpoint = options.GetHttpEndpointUri();
31+
if (httpEndpoint == null)
32+
{
33+
// Don't map OTLP HTTP route endpoints if there isn't a Kestrel endpoint to access them with.
34+
return;
35+
}
36+
2537
var group = endpoints
2638
.MapGroup("/v1")
2739
.AddOtlpHttpMetadata();
2840

41+
if (!string.IsNullOrEmpty(options.Cors.AllowedOrigins))
42+
{
43+
group = group.RequireCors(builder =>
44+
{
45+
builder.WithOrigins(options.Cors.AllowedOrigins.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
46+
builder.SetIsOriginAllowedToAllowWildcardSubdomains();
47+
48+
var allowedHeaders = !string.IsNullOrEmpty(options.Cors.AllowedHeaders)
49+
? options.Cors.AllowedHeaders.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
50+
: DefaultAllowedHeaders;
51+
builder.WithHeaders(allowedHeaders);
52+
53+
// Hardcode to allow only POST methods. OTLP is always sent in POST request bodies.
54+
builder.WithMethods(HttpMethods.Post);
55+
});
56+
}
57+
2958
group.MapPost("logs", static (MessageBindable<ExportLogsServiceRequest> request, OtlpLogsService service) =>
3059
{
3160
if (request.Message == null)

src/Shared/DashboardConfigNames.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ internal static class DashboardConfigNames
1515
public static readonly ConfigName DashboardOtlpAuthModeName = new("Dashboard:Otlp:AuthMode", "DASHBOARD__OTLP__AUTHMODE");
1616
public static readonly ConfigName DashboardOtlpPrimaryApiKeyName = new("Dashboard:Otlp:PrimaryApiKey", "DASHBOARD__OTLP__PRIMARYAPIKEY");
1717
public static readonly ConfigName DashboardOtlpSecondaryApiKeyName = new("Dashboard:Otlp:SecondaryApiKey", "DASHBOARD__OTLP__SECONDARYAPIKEY");
18+
public static readonly ConfigName DashboardOtlpCorsAllowedOriginsKeyName = new("Dashboard:Otlp:Cors:AllowedOrigins", "DASHBOARD__OTLP__CORS__ALLOWEDORIGINS");
19+
public static readonly ConfigName DashboardOtlpCorsAllowedHeadersKeyName = new("Dashboard:Otlp:Cors:AllowedHeaders", "DASHBOARD__OTLP__CORS__ALLOWEDHEADERS");
1820
public static readonly ConfigName DashboardFrontendAuthModeName = new("Dashboard:Frontend:AuthMode", "DASHBOARD__FRONTEND__AUTHMODE");
1921
public static readonly ConfigName DashboardFrontendBrowserTokenName = new("Dashboard:Frontend:BrowserToken", "DASHBOARD__FRONTEND__BROWSERTOKEN");
2022
public static readonly ConfigName DashboardFrontendMaxConsoleLogCountName = new("Dashboard:Frontend:MaxConsoleLogCount", "DASHBOARD__FRONTEND__MAXCONSOLELOGCOUNT");
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net;
5+
using Aspire.Hosting;
6+
using Xunit;
7+
using Xunit.Abstractions;
8+
9+
namespace Aspire.Dashboard.Tests.Integration;
10+
11+
public class OtlpCorsHttpServiceTests
12+
{
13+
private readonly ITestOutputHelper _testOutputHelper;
14+
15+
public OtlpCorsHttpServiceTests(ITestOutputHelper testOutputHelper)
16+
{
17+
_testOutputHelper = testOutputHelper;
18+
}
19+
20+
[Fact]
21+
public async Task ReceivePreflight_OtlpHttpEndPoint_NoCorsConfiguration_NotFound()
22+
{
23+
// Arrange
24+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper);
25+
await app.StartAsync();
26+
27+
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}");
28+
29+
var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs");
30+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
31+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type");
32+
preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000");
33+
34+
// Act
35+
var responseMessage = await httpClient.SendAsync(preflightRequest);
36+
37+
// Assert
38+
Assert.Equal(HttpStatusCode.NotFound, responseMessage.StatusCode);
39+
}
40+
41+
[Fact]
42+
public async Task ReceivePreflight_OtlpHttpEndPoint_ValidCorsOrigin_Success()
43+
{
44+
// Arrange
45+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
46+
{
47+
config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "http://localhost:8000, http://localhost:8001";
48+
});
49+
await app.StartAsync();
50+
51+
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}");
52+
53+
// Act 1
54+
var preflightRequest1 = new HttpRequestMessage(HttpMethod.Options, "/v1/logs");
55+
preflightRequest1.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
56+
preflightRequest1.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type");
57+
preflightRequest1.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000");
58+
59+
var responseMessage1 = await httpClient.SendAsync(preflightRequest1);
60+
61+
// Assert 1
62+
Assert.Equal(HttpStatusCode.NoContent, responseMessage1.StatusCode);
63+
Assert.Equal("http://localhost:8000", responseMessage1.Headers.GetValues("Access-Control-Allow-Origin").Single());
64+
Assert.Equal("POST", responseMessage1.Headers.GetValues("Access-Control-Allow-Methods").Single());
65+
Assert.Equal("X-Requested-With", responseMessage1.Headers.GetValues("Access-Control-Allow-Headers").Single());
66+
67+
// Act 2
68+
var preflightRequest2 = new HttpRequestMessage(HttpMethod.Options, "/v1/logs");
69+
preflightRequest2.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
70+
preflightRequest2.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type");
71+
preflightRequest2.Headers.TryAddWithoutValidation("Origin", "http://localhost:8001");
72+
73+
var responseMessage2 = await httpClient.SendAsync(preflightRequest2);
74+
75+
// Assert 2
76+
Assert.Equal(HttpStatusCode.NoContent, responseMessage2.StatusCode);
77+
Assert.Equal("http://localhost:8001", responseMessage2.Headers.GetValues("Access-Control-Allow-Origin").Single());
78+
Assert.Equal("POST", responseMessage2.Headers.GetValues("Access-Control-Allow-Methods").Single());
79+
Assert.Equal("X-Requested-With", responseMessage2.Headers.GetValues("Access-Control-Allow-Headers").Single());
80+
}
81+
82+
[Fact]
83+
public async Task ReceivePreflight_OtlpHttpEndPoint_InvalidCorsOrigin_NoCorsHeadersReturned()
84+
{
85+
// Arrange
86+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
87+
{
88+
config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "http://localhost:8000";
89+
});
90+
await app.StartAsync();
91+
92+
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}");
93+
94+
var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs");
95+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
96+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type");
97+
preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8001");
98+
99+
// Act
100+
var responseMessage = await httpClient.SendAsync(preflightRequest);
101+
102+
// Assert
103+
Assert.Equal(HttpStatusCode.NoContent, responseMessage.StatusCode);
104+
Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Origin"));
105+
Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Methods"));
106+
Assert.False(responseMessage.Headers.Contains("Access-Control-Allow-Headers"));
107+
}
108+
109+
[Fact]
110+
public async Task ReceivePreflight_OtlpHttpEndPoint_AnyOrigin_Success()
111+
{
112+
// Arrange
113+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config =>
114+
{
115+
config[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "*";
116+
config[DashboardConfigNames.DashboardOtlpCorsAllowedHeadersKeyName.ConfigKey] = "*";
117+
});
118+
await app.StartAsync();
119+
120+
using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.OtlpServiceHttpEndPointAccessor().EndPoint}");
121+
122+
var preflightRequest = new HttpRequestMessage(HttpMethod.Options, "/v1/logs");
123+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Method", "POST");
124+
preflightRequest.Headers.TryAddWithoutValidation("Access-Control-Request-Headers", "x-requested-with,x-custom,Content-Type");
125+
preflightRequest.Headers.TryAddWithoutValidation("Origin", "http://localhost:8000");
126+
127+
// Act
128+
var responseMessage = await httpClient.SendAsync(preflightRequest);
129+
130+
// Assert
131+
Assert.Equal(HttpStatusCode.NoContent, responseMessage.StatusCode);
132+
Assert.Equal("*", responseMessage.Headers.GetValues("Access-Control-Allow-Origin").Single());
133+
Assert.Equal("POST", responseMessage.Headers.GetValues("Access-Control-Allow-Methods").Single());
134+
Assert.Equal("x-requested-with,x-custom,Content-Type", responseMessage.Headers.GetValues("Access-Control-Allow-Headers").Single());
135+
}
136+
}

tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,22 @@ public async Task EndPointAccessors_AppStarted_BrowserGet_Success()
455455
Assert.NotEmpty(response.Headers.GetValues(HeaderNames.ContentSecurityPolicy).Single());
456456
}
457457

458+
[Fact]
459+
public async Task Configuration_CorsNoOtlpHttpEndpoint_Error()
460+
{
461+
// Arrange & Act
462+
await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper,
463+
additionalConfiguration: data =>
464+
{
465+
data.Remove(DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey);
466+
data[DashboardConfigNames.DashboardOtlpCorsAllowedOriginsKeyName.ConfigKey] = "https://localhost:666";
467+
});
468+
469+
// Assert
470+
Assert.Collection(app.ValidationFailures,
471+
s => Assert.Contains(DashboardConfigNames.DashboardOtlpHttpUrlName.ConfigKey, s));
472+
}
473+
458474
private static void AssertDynamicIPEndpoint(Func<EndpointInfo> endPointAccessor)
459475
{
460476
// Check that the specified dynamic port of 0 is overridden with the actual port number.

0 commit comments

Comments
 (0)