diff --git a/src/Service.Tests/Configuration/ConfigurationEndpointAuthorizationTests.cs b/src/Service.Tests/Configuration/ConfigurationEndpointAuthorizationTests.cs new file mode 100644 index 0000000000..fc9eb9a68e --- /dev/null +++ b/src/Service.Tests/Configuration/ConfigurationEndpointAuthorizationTests.cs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Service.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Service.Tests.Configuration.ConfigurationEndpoints; +using static Azure.DataApiBuilder.Service.Tests.Configuration.TestConfigFileReader; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration; + +/// +/// Tests for the security mitigation on the POST /configuration endpoint (CWE-306). +/// Verifies that the endpoint is restricted to loopback addresses and optionally +/// gated behind a bootstrap token (DAB_CONFIG_AUTH_TOKEN / X-DAB-CONFIG-AUTH header). +/// +[TestClass] +public class ConfigurationEndpointAuthorizationTests +{ + // Names of environment variables this test class manipulates. Snapshotted in + // TestInitialize and restored in TestCleanup so each test starts and ends from + // the same global state regardless of what other tests in the assembly do. + private const string ASPNETCORE_ENVIRONMENT_VAR = "ASPNETCORE_ENVIRONMENT"; + private const string DAB_ENVIRONMENT_VAR = "DAB_ENVIRONMENT"; + + private string _originalAuthToken; + private string _originalAspNetEnvironment; + private string _originalDabEnvironment; + + [TestInitialize] + public void Initialize() + { + // Snapshot the originals so they can be restored verbatim in TestCleanup. + _originalAuthToken = Environment.GetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR); + _originalAspNetEnvironment = Environment.GetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR); + _originalDabEnvironment = Environment.GetEnvironmentVariable(DAB_ENVIRONMENT_VAR); + + // Other tests in the assembly (e.g. TestLoadingLocalCosmosSettings) set + // ASPNETCORE_ENVIRONMENT / DAB_ENVIRONMENT without cleanup. Those env vars cause + // FileSystemRuntimeConfigLoader to find an environment-specific dab-config.*.json on + // disk and auto-initialize the runtime, which makes POST /configuration return + // 409 Conflict before our security middleware can be exercised. Clear them so each + // test starts from an uninitialized runtime; the originals are restored in cleanup. + Environment.SetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR, null); + Environment.SetEnvironmentVariable(DAB_ENVIRONMENT_VAR, null); + } + + [TestCleanup] + public void Cleanup() + { + Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, _originalAuthToken); + Environment.SetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR, _originalAspNetEnvironment); + Environment.SetEnvironmentVariable(DAB_ENVIRONMENT_VAR, _originalDabEnvironment); + } + + /// + /// Validates IsConfigurationRequestAuthorized across the full matrix of inputs: + /// remote IP (loopback v4/v6, private/public, null/in-process), configured bootstrap + /// token, and provided X-DAB-CONFIG-AUTH header value. + /// + /// + /// Source IP for the simulated request. Use null to model an in-process call + /// (e.g. TestServer) where there is no underlying TCP connection. + /// + /// Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset. + /// Value of the X-DAB-CONFIG-AUTH header, or null to omit. + /// Expected authorization result. + [DataTestMethod] + // --- No bootstrap token configured --- + [DataRow("127.0.0.1", null, null, true, DisplayName = "Loopback IPv4, no token => allow")] + [DataRow("::1", null, null, true, DisplayName = "Loopback IPv6, no token => allow")] + [DataRow("::ffff:127.0.0.1", null, null, true, DisplayName = "IPv4-mapped IPv6 loopback (dual-stack Kestrel), no token => allow")] + [DataRow(null, null, null, true, DisplayName = "In-process (null IP), no token => allow")] + [DataRow("192.168.1.100", null, null, false, DisplayName = "Private IPv4, no token => deny")] + [DataRow("10.0.0.1", null, null, false, DisplayName = "Private IPv4 (10/8), no token => deny")] + [DataRow("172.17.0.1", null, null, false, DisplayName = "Docker bridge IP, no token => deny")] + [DataRow("203.0.113.50", null, null, false, DisplayName = "Public IPv4, no token => deny")] + // --- Bootstrap token configured --- + [DataRow("127.0.0.1", "secret", "secret", true, DisplayName = "Loopback + correct token => allow")] + [DataRow("127.0.0.1", "secret", "wrong", false, DisplayName = "Loopback + wrong token => deny")] + [DataRow("127.0.0.1", "secret", null, false, DisplayName = "Loopback + missing header => deny")] + [DataRow("192.168.1.50", "secret", "secret", false, DisplayName = "Private IPv4 + correct token => still deny (non-loopback)")] + [DataRow("192.168.1.50", "secret", "wrong", false, DisplayName = "Private IPv4 + wrong token => deny")] + [DataRow("203.0.113.50", "secret", "secret", false, DisplayName = "Public IPv4 + correct token => still deny (non-loopback)")] + [DataRow("203.0.113.50", "secret", "wrong", false, DisplayName = "Public IPv4 + wrong token => deny")] + [DataRow("203.0.113.50", "secret", null, false, DisplayName = "Public IPv4 + missing header => deny")] + [DataRow(null, "secret", null, false, DisplayName = "In-process + missing header => deny")] + [DataRow(null, "secret", "secret", true, DisplayName = "In-process + correct token => allow")] + public void IsConfigurationRequestAuthorized_Matrix( + string remoteIp, + string configuredToken, + string providedHeader, + bool expected) + { + // Arrange + Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken); + + DefaultHttpContext httpContext = new(); + httpContext.Connection.RemoteIpAddress = remoteIp is null ? null : IPAddress.Parse(remoteIp); + if (providedHeader is not null) + { + httpContext.Request.Headers[Startup.CONFIG_AUTH_HEADER] = providedHeader; + } + + // Act + bool result = Startup.IsConfigurationRequestAuthorized(httpContext); + + // Assert + Assert.AreEqual(expected, result); + } + + /// + /// End-to-end test against the full middleware pipeline via TestServer. Drives both + /// /configuration (v1) and /configuration/v2 with every token combination and verifies + /// the HTTP status code returned by the security middleware. + /// + /// The endpoint being tested. + /// Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset. + /// Value of the X-DAB-CONFIG-AUTH header, or null to omit. + /// Expected HTTP status code from the POST. + [DataTestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)] + // No token configured -- backward-compatible loopback success + [DataRow(CONFIGURATION_ENDPOINT, null, null, HttpStatusCode.OK)] + [DataRow(CONFIGURATION_ENDPOINT_V2, null, null, HttpStatusCode.OK)] + // Token configured and correct -- allowed + [DataRow(CONFIGURATION_ENDPOINT, "integration-token", "integration-token", HttpStatusCode.OK)] + [DataRow(CONFIGURATION_ENDPOINT_V2, "integration-token", "integration-token", HttpStatusCode.OK)] + // Token configured but wrong -- forbidden + [DataRow(CONFIGURATION_ENDPOINT, "correct-token", "wrong-token", HttpStatusCode.Forbidden)] + [DataRow(CONFIGURATION_ENDPOINT_V2, "correct-token", "wrong-token", HttpStatusCode.Forbidden)] + // Token configured but header missing -- forbidden + [DataRow(CONFIGURATION_ENDPOINT, "required-token", null, HttpStatusCode.Forbidden)] + [DataRow(CONFIGURATION_ENDPOINT_V2, "required-token", null, HttpStatusCode.Forbidden)] + public async Task EndToEnd_PostConfiguration_StatusCodeMatrix( + string configurationEndpoint, + string configuredToken, + string providedHeader, + HttpStatusCode expectedStatus) + { + // Arrange -- ASPNETCORE_ENVIRONMENT / DAB_ENVIRONMENT are already cleared by + // TestInitialize so the runtime starts uninitialized and our middleware is + // exercised. Only the per-row auth token still needs to be set here; TestCleanup + // restores the original value. + Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken); + + using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty())); + using HttpClient httpClient = server.CreateClient(); + + HttpRequestMessage request = new(HttpMethod.Post, configurationEndpoint) + { + Content = BuildPostContent(configurationEndpoint), + }; + if (providedHeader is not null) + { + request.Headers.Add(Startup.CONFIG_AUTH_HEADER, providedHeader); + } + + // Act + HttpResponseMessage postResult = await httpClient.SendAsync(request); + + // Assert + Assert.AreEqual(expectedStatus, postResult.StatusCode); + } + + /// + /// Builds the JSON post body appropriate for the given configuration endpoint version. + /// + private static JsonContent BuildPostContent(string endpoint) + { + Config.ObjectModel.RuntimeConfig config = ReadCosmosConfigurationFromFile() with { Schema = "@env('schema')" }; + const string graphqlSchema = @" + type Entity { + id: ID! + name: String! + } + "; + + if (endpoint == CONFIGURATION_ENDPOINT) + { + return JsonContent.Create(new ConfigurationPostParameters( + Configuration: config.ToJson(), + Schema: graphqlSchema, + ConnectionString: "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==", + AccessToken: null)); + } + + if (endpoint == CONFIGURATION_ENDPOINT_V2) + { + return JsonContent.Create(new ConfigurationPostParametersV2( + Configuration: config.ToJson(), + ConfigurationOverrides: "{}", + Schema: graphqlSchema, + AccessToken: null)); + } + + throw new ArgumentException($"Unknown endpoint: {endpoint}", nameof(endpoint)); + } +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 876b63378e..520a95b90d 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -7,6 +7,8 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config; @@ -89,6 +91,19 @@ public class Startup(IConfiguration configuration, ILogger logger) public static AzureLogAnalyticsOptions AzureLogAnalyticsOptions = new(); public static FileSinkOptions FileSinkOptions = new(); public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect"; + + /// + /// Environment variable name for the optional bootstrap token that gates access to the + /// POST /configuration endpoint in late-configured (hosted) mode. When set, the request + /// must include an X-DAB-CONFIG-AUTH header whose value matches this token. + /// + internal const string CONFIG_AUTH_TOKEN_ENV_VAR = "DAB_CONFIG_AUTH_TOKEN"; + + /// + /// Header name used to supply the configuration bootstrap token. + /// + internal const string CONFIG_AUTH_HEADER = "X-DAB-CONFIG-AUTH"; + private readonly HotReloadEventHandler _hotReloadEventHandler = new(); private RuntimeConfigProvider? _configProvider; private ILogger _logger = logger; @@ -949,6 +964,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC { context.Response.StatusCode = StatusCodes.Status409Conflict; } + else if (!IsConfigurationRequestAuthorized(context)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + } else { await next.Invoke(); @@ -1479,6 +1498,57 @@ private static bool IsUIEnabled(RuntimeConfig? runtimeConfig, IWebHostEnvironmen return (runtimeConfig is not null && runtimeConfig.IsDevelopmentMode()) || env.IsDevelopment(); } + /// + /// Determines whether a POST /configuration request is authorized by enforcing: + /// 1. The request must originate from a loopback address (localhost / 127.0.0.1 / ::1 + /// or its IPv4-mapped IPv6 form such as ::ffff:127.0.0.1). + /// 2. If the DAB_CONFIG_AUTH_TOKEN environment variable is set, the request must include + /// an X-DAB-CONFIG-AUTH header whose value matches the token (constant-time comparison). + /// This prevents unauthenticated network-remote callers from supplying the runtime + /// configuration during the late-configured bootstrap window. + /// + internal static bool IsConfigurationRequestAuthorized(HttpContext context) + { + // Defense 1: Restrict to loopback addresses only. + // A null RemoteIpAddress indicates an in-process call (e.g. TestServer) + // where there is no underlying TCP connection — by definition this cannot + // be a remote attacker, so we treat it as loopback-equivalent. A real + // network-borne request always carries a TCP source IP. + IPAddress? remoteIp = context.Connection.RemoteIpAddress; + if (remoteIp is not null) + { + // Kestrel often listens on dual-stack IPv6, so an IPv4 loopback caller can + // arrive as the IPv4-mapped IPv6 form (e.g. ::ffff:127.0.0.1). IPAddress.IsLoopback + // does not treat those as loopback, so normalize to IPv4 first. + IPAddress effectiveIp = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp; + if (!IPAddress.IsLoopback(effectiveIp)) + { + return false; + } + } + + // Defense 2: If a bootstrap token is configured, require it in the request header. + string? expectedToken = Environment.GetEnvironmentVariable(CONFIG_AUTH_TOKEN_ENV_VAR); + if (!string.IsNullOrEmpty(expectedToken)) + { + string? providedToken = context.Request.Headers[CONFIG_AUTH_HEADER].FirstOrDefault(); + if (string.IsNullOrEmpty(providedToken)) + { + return false; + } + + // Use fixed-time comparison to prevent timing attacks. + if (!CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expectedToken), + Encoding.UTF8.GetBytes(providedToken))) + { + return false; + } + } + + return true; + } + /// /// Checks whether On-Behalf-Of (OBO) authentication is configured by verifying that /// the required environment variables are set and the config has user-delegated auth enabled.