Skip to content

Commit 109ee47

Browse files
[CherryPick 2.0] Restrict POST /configuration endpoint to loopback (#3671)
Cherry-pick of #3669 (commit 7a0d7e3) onto `release/2.0`. ## Summary Restricts the late-configured `POST /configuration` endpoint to loopback callers, with an optional bootstrap token via `DAB_CONFIG_AUTH_TOKEN` / `X-DAB-CONFIG-AUTH` (fixed-time comparison). Addresses CWE-306 on the hosted-mode bootstrap channel. ## Change - `src/Service/Startup.cs`: added `IsConfigurationRequestAuthorized(HttpContext)` and wired it into the existing `POST /configuration` gating middleware (returns 403 when unauthorized). Null `RemoteIpAddress` (in-process callers like TestServer) treated as loopback. IPv4-mapped IPv6 normalized via `MapToIPv4()` before `IPAddress.IsLoopback`. - `src/Service.Tests/Configuration/ConfigurationEndpointAuthorizationTests.cs`: 26 tests (18 unit matrix + 8 end-to-end TestServer rows). ## Backward compatibility - Azure Static Web Apps platform config injector (loopback inside the container) keeps working unchanged. - In-process `TestServer` callers keep working unchanged. - Bootstrap token is opt-in -- behavior on loopback matches today's when `DAB_CONFIG_AUTH_TOKEN` is unset. ## Validation - Clean cherry-pick (no conflicts; `src/Service/Startup.cs` auto-merged). - `dotnet build` clean (0 warnings, 0 errors). - All 26 `ConfigurationEndpointAuthorizationTests` pass locally on the cherry-pick branch.
1 parent 36a5fea commit 109ee47

2 files changed

Lines changed: 274 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Net.Http.Json;
8+
using System.Threading.Tasks;
9+
using Azure.DataApiBuilder.Service.Controllers;
10+
using Microsoft.AspNetCore.Http;
11+
using Microsoft.AspNetCore.TestHost;
12+
using Microsoft.VisualStudio.TestTools.UnitTesting;
13+
using static Azure.DataApiBuilder.Service.Tests.Configuration.ConfigurationEndpoints;
14+
using static Azure.DataApiBuilder.Service.Tests.Configuration.TestConfigFileReader;
15+
16+
namespace Azure.DataApiBuilder.Service.Tests.Configuration;
17+
18+
/// <summary>
19+
/// Tests for the security mitigation on the POST /configuration endpoint (CWE-306).
20+
/// Verifies that the endpoint is restricted to loopback addresses and optionally
21+
/// gated behind a bootstrap token (DAB_CONFIG_AUTH_TOKEN / X-DAB-CONFIG-AUTH header).
22+
/// </summary>
23+
[TestClass]
24+
public class ConfigurationEndpointAuthorizationTests
25+
{
26+
// Names of environment variables this test class manipulates. Snapshotted in
27+
// TestInitialize and restored in TestCleanup so each test starts and ends from
28+
// the same global state regardless of what other tests in the assembly do.
29+
private const string ASPNETCORE_ENVIRONMENT_VAR = "ASPNETCORE_ENVIRONMENT";
30+
private const string DAB_ENVIRONMENT_VAR = "DAB_ENVIRONMENT";
31+
32+
private string _originalAuthToken;
33+
private string _originalAspNetEnvironment;
34+
private string _originalDabEnvironment;
35+
36+
[TestInitialize]
37+
public void Initialize()
38+
{
39+
// Snapshot the originals so they can be restored verbatim in TestCleanup.
40+
_originalAuthToken = Environment.GetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR);
41+
_originalAspNetEnvironment = Environment.GetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR);
42+
_originalDabEnvironment = Environment.GetEnvironmentVariable(DAB_ENVIRONMENT_VAR);
43+
44+
// Other tests in the assembly (e.g. TestLoadingLocalCosmosSettings) set
45+
// ASPNETCORE_ENVIRONMENT / DAB_ENVIRONMENT without cleanup. Those env vars cause
46+
// FileSystemRuntimeConfigLoader to find an environment-specific dab-config.*.json on
47+
// disk and auto-initialize the runtime, which makes POST /configuration return
48+
// 409 Conflict before our security middleware can be exercised. Clear them so each
49+
// test starts from an uninitialized runtime; the originals are restored in cleanup.
50+
Environment.SetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR, null);
51+
Environment.SetEnvironmentVariable(DAB_ENVIRONMENT_VAR, null);
52+
}
53+
54+
[TestCleanup]
55+
public void Cleanup()
56+
{
57+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, _originalAuthToken);
58+
Environment.SetEnvironmentVariable(ASPNETCORE_ENVIRONMENT_VAR, _originalAspNetEnvironment);
59+
Environment.SetEnvironmentVariable(DAB_ENVIRONMENT_VAR, _originalDabEnvironment);
60+
}
61+
62+
/// <summary>
63+
/// Validates IsConfigurationRequestAuthorized across the full matrix of inputs:
64+
/// remote IP (loopback v4/v6, private/public, null/in-process), configured bootstrap
65+
/// token, and provided X-DAB-CONFIG-AUTH header value.
66+
/// </summary>
67+
/// <param name="remoteIp">
68+
/// Source IP for the simulated request. Use null to model an in-process call
69+
/// (e.g. TestServer) where there is no underlying TCP connection.
70+
/// </param>
71+
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
72+
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
73+
/// <param name="expected">Expected authorization result.</param>
74+
[DataTestMethod]
75+
// --- No bootstrap token configured ---
76+
[DataRow("127.0.0.1", null, null, true, DisplayName = "Loopback IPv4, no token => allow")]
77+
[DataRow("::1", null, null, true, DisplayName = "Loopback IPv6, no token => allow")]
78+
[DataRow("::ffff:127.0.0.1", null, null, true, DisplayName = "IPv4-mapped IPv6 loopback (dual-stack Kestrel), no token => allow")]
79+
[DataRow(null, null, null, true, DisplayName = "In-process (null IP), no token => allow")]
80+
[DataRow("192.168.1.100", null, null, false, DisplayName = "Private IPv4, no token => deny")]
81+
[DataRow("10.0.0.1", null, null, false, DisplayName = "Private IPv4 (10/8), no token => deny")]
82+
[DataRow("172.17.0.1", null, null, false, DisplayName = "Docker bridge IP, no token => deny")]
83+
[DataRow("203.0.113.50", null, null, false, DisplayName = "Public IPv4, no token => deny")]
84+
// --- Bootstrap token configured ---
85+
[DataRow("127.0.0.1", "secret", "secret", true, DisplayName = "Loopback + correct token => allow")]
86+
[DataRow("127.0.0.1", "secret", "wrong", false, DisplayName = "Loopback + wrong token => deny")]
87+
[DataRow("127.0.0.1", "secret", null, false, DisplayName = "Loopback + missing header => deny")]
88+
[DataRow("192.168.1.50", "secret", "secret", false, DisplayName = "Private IPv4 + correct token => still deny (non-loopback)")]
89+
[DataRow("192.168.1.50", "secret", "wrong", false, DisplayName = "Private IPv4 + wrong token => deny")]
90+
[DataRow("203.0.113.50", "secret", "secret", false, DisplayName = "Public IPv4 + correct token => still deny (non-loopback)")]
91+
[DataRow("203.0.113.50", "secret", "wrong", false, DisplayName = "Public IPv4 + wrong token => deny")]
92+
[DataRow("203.0.113.50", "secret", null, false, DisplayName = "Public IPv4 + missing header => deny")]
93+
[DataRow(null, "secret", null, false, DisplayName = "In-process + missing header => deny")]
94+
[DataRow(null, "secret", "secret", true, DisplayName = "In-process + correct token => allow")]
95+
public void IsConfigurationRequestAuthorized_Matrix(
96+
string remoteIp,
97+
string configuredToken,
98+
string providedHeader,
99+
bool expected)
100+
{
101+
// Arrange
102+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken);
103+
104+
DefaultHttpContext httpContext = new();
105+
httpContext.Connection.RemoteIpAddress = remoteIp is null ? null : IPAddress.Parse(remoteIp);
106+
if (providedHeader is not null)
107+
{
108+
httpContext.Request.Headers[Startup.CONFIG_AUTH_HEADER] = providedHeader;
109+
}
110+
111+
// Act
112+
bool result = Startup.IsConfigurationRequestAuthorized(httpContext);
113+
114+
// Assert
115+
Assert.AreEqual(expected, result);
116+
}
117+
118+
/// <summary>
119+
/// End-to-end test against the full middleware pipeline via TestServer. Drives both
120+
/// /configuration (v1) and /configuration/v2 with every token combination and verifies
121+
/// the HTTP status code returned by the security middleware.
122+
/// </summary>
123+
/// <param name="configurationEndpoint">The endpoint being tested.</param>
124+
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
125+
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
126+
/// <param name="expectedStatus">Expected HTTP status code from the POST.</param>
127+
[DataTestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)]
128+
// No token configured -- backward-compatible loopback success
129+
[DataRow(CONFIGURATION_ENDPOINT, null, null, HttpStatusCode.OK)]
130+
[DataRow(CONFIGURATION_ENDPOINT_V2, null, null, HttpStatusCode.OK)]
131+
// Token configured and correct -- allowed
132+
[DataRow(CONFIGURATION_ENDPOINT, "integration-token", "integration-token", HttpStatusCode.OK)]
133+
[DataRow(CONFIGURATION_ENDPOINT_V2, "integration-token", "integration-token", HttpStatusCode.OK)]
134+
// Token configured but wrong -- forbidden
135+
[DataRow(CONFIGURATION_ENDPOINT, "correct-token", "wrong-token", HttpStatusCode.Forbidden)]
136+
[DataRow(CONFIGURATION_ENDPOINT_V2, "correct-token", "wrong-token", HttpStatusCode.Forbidden)]
137+
// Token configured but header missing -- forbidden
138+
[DataRow(CONFIGURATION_ENDPOINT, "required-token", null, HttpStatusCode.Forbidden)]
139+
[DataRow(CONFIGURATION_ENDPOINT_V2, "required-token", null, HttpStatusCode.Forbidden)]
140+
public async Task EndToEnd_PostConfiguration_StatusCodeMatrix(
141+
string configurationEndpoint,
142+
string configuredToken,
143+
string providedHeader,
144+
HttpStatusCode expectedStatus)
145+
{
146+
// Arrange -- ASPNETCORE_ENVIRONMENT / DAB_ENVIRONMENT are already cleared by
147+
// TestInitialize so the runtime starts uninitialized and our middleware is
148+
// exercised. Only the per-row auth token still needs to be set here; TestCleanup
149+
// restores the original value.
150+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken);
151+
152+
using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty<string>()));
153+
using HttpClient httpClient = server.CreateClient();
154+
155+
HttpRequestMessage request = new(HttpMethod.Post, configurationEndpoint)
156+
{
157+
Content = BuildPostContent(configurationEndpoint),
158+
};
159+
if (providedHeader is not null)
160+
{
161+
request.Headers.Add(Startup.CONFIG_AUTH_HEADER, providedHeader);
162+
}
163+
164+
// Act
165+
HttpResponseMessage postResult = await httpClient.SendAsync(request);
166+
167+
// Assert
168+
Assert.AreEqual(expectedStatus, postResult.StatusCode);
169+
}
170+
171+
/// <summary>
172+
/// Builds the JSON post body appropriate for the given configuration endpoint version.
173+
/// </summary>
174+
private static JsonContent BuildPostContent(string endpoint)
175+
{
176+
Config.ObjectModel.RuntimeConfig config = ReadCosmosConfigurationFromFile() with { Schema = "@env('schema')" };
177+
const string graphqlSchema = @"
178+
type Entity {
179+
id: ID!
180+
name: String!
181+
}
182+
";
183+
184+
if (endpoint == CONFIGURATION_ENDPOINT)
185+
{
186+
return JsonContent.Create(new ConfigurationPostParameters(
187+
Configuration: config.ToJson(),
188+
Schema: graphqlSchema,
189+
ConnectionString: "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
190+
AccessToken: null));
191+
}
192+
193+
if (endpoint == CONFIGURATION_ENDPOINT_V2)
194+
{
195+
return JsonContent.Create(new ConfigurationPostParametersV2(
196+
Configuration: config.ToJson(),
197+
ConfigurationOverrides: "{}",
198+
Schema: graphqlSchema,
199+
AccessToken: null));
200+
}
201+
202+
throw new ArgumentException($"Unknown endpoint: {endpoint}", nameof(endpoint));
203+
}
204+
}

src/Service/Startup.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using System.Net;
88
using System.Net.Http;
99
using System.Net.Http.Headers;
10+
using System.Security.Cryptography;
11+
using System.Text;
1012
using System.Threading.Tasks;
1113
using Azure.DataApiBuilder.Auth;
1214
using Azure.DataApiBuilder.Config;
@@ -86,6 +88,19 @@ public class Startup(IConfiguration configuration, ILogger<Startup> logger)
8688
public static AzureLogAnalyticsOptions AzureLogAnalyticsOptions = new();
8789
public static FileSinkOptions FileSinkOptions = new();
8890
public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect";
91+
92+
/// <summary>
93+
/// Environment variable name for the optional bootstrap token that gates access to the
94+
/// POST /configuration endpoint in late-configured (hosted) mode. When set, the request
95+
/// must include an X-DAB-CONFIG-AUTH header whose value matches this token.
96+
/// </summary>
97+
internal const string CONFIG_AUTH_TOKEN_ENV_VAR = "DAB_CONFIG_AUTH_TOKEN";
98+
99+
/// <summary>
100+
/// Header name used to supply the configuration bootstrap token.
101+
/// </summary>
102+
internal const string CONFIG_AUTH_HEADER = "X-DAB-CONFIG-AUTH";
103+
89104
private readonly HotReloadEventHandler<HotReloadEventArgs> _hotReloadEventHandler = new();
90105
private RuntimeConfigProvider? _configProvider;
91106
private ILogger<Startup> _logger = logger;
@@ -877,6 +892,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC
877892
{
878893
context.Response.StatusCode = StatusCodes.Status409Conflict;
879894
}
895+
else if (!IsConfigurationRequestAuthorized(context))
896+
{
897+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
898+
}
880899
else
881900
{
882901
await next.Invoke();
@@ -1370,6 +1389,57 @@ private static bool IsUIEnabled(RuntimeConfig? runtimeConfig, IWebHostEnvironmen
13701389
return (runtimeConfig is not null && runtimeConfig.IsDevelopmentMode()) || env.IsDevelopment();
13711390
}
13721391

1392+
/// <summary>
1393+
/// Determines whether a POST /configuration request is authorized by enforcing:
1394+
/// 1. The request must originate from a loopback address (localhost / 127.0.0.1 / ::1
1395+
/// or its IPv4-mapped IPv6 form such as ::ffff:127.0.0.1).
1396+
/// 2. If the DAB_CONFIG_AUTH_TOKEN environment variable is set, the request must include
1397+
/// an X-DAB-CONFIG-AUTH header whose value matches the token (constant-time comparison).
1398+
/// This prevents unauthenticated network-remote callers from supplying the runtime
1399+
/// configuration during the late-configured bootstrap window.
1400+
/// </summary>
1401+
internal static bool IsConfigurationRequestAuthorized(HttpContext context)
1402+
{
1403+
// Defense 1: Restrict to loopback addresses only.
1404+
// A null RemoteIpAddress indicates an in-process call (e.g. TestServer)
1405+
// where there is no underlying TCP connection — by definition this cannot
1406+
// be a remote attacker, so we treat it as loopback-equivalent. A real
1407+
// network-borne request always carries a TCP source IP.
1408+
IPAddress? remoteIp = context.Connection.RemoteIpAddress;
1409+
if (remoteIp is not null)
1410+
{
1411+
// Kestrel often listens on dual-stack IPv6, so an IPv4 loopback caller can
1412+
// arrive as the IPv4-mapped IPv6 form (e.g. ::ffff:127.0.0.1). IPAddress.IsLoopback
1413+
// does not treat those as loopback, so normalize to IPv4 first.
1414+
IPAddress effectiveIp = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp;
1415+
if (!IPAddress.IsLoopback(effectiveIp))
1416+
{
1417+
return false;
1418+
}
1419+
}
1420+
1421+
// Defense 2: If a bootstrap token is configured, require it in the request header.
1422+
string? expectedToken = Environment.GetEnvironmentVariable(CONFIG_AUTH_TOKEN_ENV_VAR);
1423+
if (!string.IsNullOrEmpty(expectedToken))
1424+
{
1425+
string? providedToken = context.Request.Headers[CONFIG_AUTH_HEADER].FirstOrDefault();
1426+
if (string.IsNullOrEmpty(providedToken))
1427+
{
1428+
return false;
1429+
}
1430+
1431+
// Use fixed-time comparison to prevent timing attacks.
1432+
if (!CryptographicOperations.FixedTimeEquals(
1433+
Encoding.UTF8.GetBytes(expectedToken),
1434+
Encoding.UTF8.GetBytes(providedToken)))
1435+
{
1436+
return false;
1437+
}
1438+
}
1439+
1440+
return true;
1441+
}
1442+
13731443
/// <summary>
13741444
/// Checks whether On-Behalf-Of (OBO) authentication is configured by verifying that
13751445
/// the required environment variables are set and the config has user-delegated auth enabled.

0 commit comments

Comments
 (0)