Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="remoteIp">
/// Source IP for the simulated request. Use null to model an in-process call
/// (e.g. TestServer) where there is no underlying TCP connection.
/// </param>
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
/// <param name="expected">Expected authorization result.</param>
[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")]
Comment thread
souvikghosh04 marked this conversation as resolved.
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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="configurationEndpoint">The endpoint being tested.</param>
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
/// <param name="expectedStatus">Expected HTTP status code from the POST.</param>
[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<string>()));
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);
}

/// <summary>
/// Builds the JSON post body appropriate for the given configuration endpoint version.
/// </summary>
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));
}
}
70 changes: 70 additions & 0 deletions src/Service/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +91,19 @@ public class Startup(IConfiguration configuration, ILogger<Startup> logger)
public static AzureLogAnalyticsOptions AzureLogAnalyticsOptions = new();
public static FileSinkOptions FileSinkOptions = new();
public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect";

/// <summary>
/// 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.
/// </summary>
internal const string CONFIG_AUTH_TOKEN_ENV_VAR = "DAB_CONFIG_AUTH_TOKEN";

/// <summary>
/// Header name used to supply the configuration bootstrap token.
/// </summary>
internal const string CONFIG_AUTH_HEADER = "X-DAB-CONFIG-AUTH";

private readonly HotReloadEventHandler<HotReloadEventArgs> _hotReloadEventHandler = new();
private RuntimeConfigProvider? _configProvider;
private ILogger<Startup> _logger = logger;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1479,6 +1498,57 @@ private static bool IsUIEnabled(RuntimeConfig? runtimeConfig, IWebHostEnvironmen
return (runtimeConfig is not null && runtimeConfig.IsDevelopmentMode()) || env.IsDevelopment();
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// 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.
Expand Down