diff --git a/docs/specs/dashboard-http-api.md b/docs/specs/dashboard-http-api.md index e1daf534b38..4b6e62c8882 100644 --- a/docs/specs/dashboard-http-api.md +++ b/docs/specs/dashboard-http-api.md @@ -49,7 +49,7 @@ The API can be enabled/disabled and configured via `Dashboard:Api` settings: | Setting | Values | Default | Description | |---------|--------|---------|-------------| -| `Enabled` | `true`, `false` | `true` | Whether the Telemetry HTTP API is enabled | +| `Enabled` | `true`, `false` | `true` when frontend is unsecured or an API key is configured; `false` when the frontend requires authentication and no API key is set | Whether the Telemetry HTTP API is enabled | | `AuthMode` | `ApiKey`, `Unsecured` | `Unsecured` | Authentication mode for the API | | `PrimaryApiKey` | string | - | API key for authentication (required when `AuthMode=ApiKey`) | | `SecondaryApiKey` | string | - | Optional secondary API key for key rotation | @@ -58,6 +58,7 @@ The API can be enabled/disabled and configured via `Dashboard:Api` settings: - The API shares the same port as the Dashboard frontend (default: 18888). - Hosters may set `Enabled: false` to disable the API for security. +- When the frontend requires authentication (e.g., OpenID Connect) and no API key is configured, the API is disabled by default. Set `Enabled: true` explicitly to override. - API keys are shared with MCP configuration—setting either configures both. - When running in unsecured mode, a warning is logged on first API request. diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 7a14a80af5e..d76daf200a8 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -149,8 +149,10 @@ public sealed class ApiOptions /// /// Gets or sets whether the Telemetry HTTP API is enabled. - /// When false, the /api/telemetry/* endpoints are not registered. - /// Defaults to true. + /// When , the /api/telemetry/* endpoints are not registered. + /// Defaults to when the dashboard frontend is unsecured or an API key is configured. + /// Defaults to when the frontend requires authentication (e.g., OpenID Connect + /// or BrowserToken) and no API key is configured, to prevent unauthenticated access to telemetry data. /// public bool? Enabled { get; set; } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index b166dfda5b4..717a400c119 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -73,6 +73,16 @@ public void PostConfigure(string? name, DashboardOptions options) // If an API key is configured, default to ApiKey auth mode instead of Unsecured. options.Mcp.AuthMode ??= string.IsNullOrEmpty(options.Mcp.PrimaryApiKey) ? McpAuthMode.Unsecured : McpAuthMode.ApiKey; options.Api.AuthMode ??= string.IsNullOrEmpty(options.Api.PrimaryApiKey) ? ApiAuthMode.Unsecured : ApiAuthMode.ApiKey; + + // When the frontend requires authentication (e.g. OpenID Connect or BrowserToken) but the API + // has no key configured (and would therefore default to Unsecured), disable the API by default + // to prevent unauthenticated access to telemetry data on the same port. + if (options.Api.Enabled is null && + options.Frontend.AuthMode is not FrontendAuthMode.Unsecured && + options.Api.AuthMode is ApiAuthMode.Unsecured) + { + options.Api.Enabled = false; + } } if (options.Frontend.AuthMode == FrontendAuthMode.BrowserToken && string.IsNullOrEmpty(options.Frontend.BrowserToken)) diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index 822559e11ff..05f67c3801c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -82,6 +82,86 @@ public async Task Configuration_ApiKeyExplicit_OverridesMcp() Assert.Equal(apiKey.Length, options.Api.GetPrimaryApiKeyBytesOrNull()!.Length); } + [Fact] + public async Task Configuration_ApiDefaultsToDisabled_WhenFrontendIsBrowserToken() + { + // Arrange - BrowserToken frontend with no API key configured + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + // Don't set any Api config — no API key + }); + await app.StartAsync().DefaultTimeout(); + + // Assert - API should default to disabled when frontend has auth but no API key + var options = app.Services.GetRequiredService>().CurrentValue; + Assert.False(options.Api.Enabled); + } + + [Fact] + public async Task Configuration_ApiStaysEnabled_WhenExplicitlyEnabled() + { + // Arrange - BrowserToken frontend, explicitly enable API with API key auth + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + config[DashboardConfigNames.DashboardApiEnabledName.ConfigKey] = "true"; + config[DashboardConfigNames.DashboardApiAuthModeName.ConfigKey] = ApiAuthMode.ApiKey.ToString(); + config[DashboardConfigNames.DashboardApiPrimaryApiKeyName.ConfigKey] = apiKey; + }); + await app.StartAsync().DefaultTimeout(); + + // Assert - API should be enabled because explicitly configured + var options = app.Services.GetRequiredService>().CurrentValue; + Assert.True(options.Api.Enabled); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation(ApiAuthenticationHandler.ApiKeyHeaderName, apiKey); + var response = await httpClient.GetAsync("/api/telemetry/spans").DefaultTimeout(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Configuration_ApiStaysEnabled_WhenApiKeyConfigured() + { + // Arrange - BrowserToken frontend with API key configured (should auto-enable ApiKey auth mode) + var apiKey = "TestKey123!"; + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.BrowserToken.ToString(); + config[DashboardConfigNames.DashboardApiPrimaryApiKeyName.ConfigKey] = apiKey; + }); + await app.StartAsync().DefaultTimeout(); + + // Assert - API should be enabled because an API key is configured (AuthMode defaults to ApiKey) + var options = app.Services.GetRequiredService>().CurrentValue; + Assert.Equal(ApiAuthMode.ApiKey, options.Api.AuthMode); + Assert.NotEqual(false, options.Api.Enabled); + } + + [Fact] + public async Task Configuration_ApiStaysEnabled_WhenFrontendUnsecured() + { + // Arrange - Unsecured frontend with no API key configured + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => + { + config[DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = FrontendAuthMode.Unsecured.ToString(); + // Don't set any Api config + }); + await app.StartAsync().DefaultTimeout(); + + // Assert - API should remain enabled when frontend is also unsecured + var options = app.Services.GetRequiredService>().CurrentValue; + Assert.NotEqual(false, options.Api.Enabled); + Assert.Equal(ApiAuthMode.Unsecured, options.Api.AuthMode); + + // Verify endpoints work + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); + var response = await httpClient.GetAsync("/api/telemetry/spans").DefaultTimeout(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + #endregion [Fact]