Skip to content

Commit 7180daf

Browse files
committed
Restrict POST /configuration endpoint to loopback
The POST /configuration endpoint used in late-configured (hosted) mode now requires the request to originate from a loopback address. An optional bootstrap token (DAB_CONFIG_AUTH_TOKEN env var, X-DAB-CONFIG-AUTH header) can be required as an additional check. Backward compatible: in-process callers (TestServer) and loopback callers without a token continue to work.
1 parent bfc4f3d commit 7180daf

2 files changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
/// <summary>
27+
/// Validates IsConfigurationRequestAuthorized across the full matrix of inputs:
28+
/// remote IP (loopback v4/v6, private/public, null/in-process), configured bootstrap
29+
/// token, and provided X-DAB-CONFIG-AUTH header value.
30+
/// </summary>
31+
/// <param name="remoteIp">
32+
/// Source IP for the simulated request. Use null to model an in-process call
33+
/// (e.g. TestServer) where there is no underlying TCP connection.
34+
/// </param>
35+
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
36+
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
37+
/// <param name="expected">Expected authorization result.</param>
38+
[DataTestMethod]
39+
// --- No bootstrap token configured ---
40+
[DataRow("127.0.0.1", null, null, true, DisplayName = "Loopback IPv4, no token => allow")]
41+
[DataRow("::1", null, null, true, DisplayName = "Loopback IPv6, no token => allow")]
42+
[DataRow(null, null, null, true, DisplayName = "In-process (null IP), no token => allow")]
43+
[DataRow("192.168.1.100", null, null, false, DisplayName = "Private IPv4, no token => deny")]
44+
[DataRow("10.0.0.1", null, null, false, DisplayName = "Private IPv4 (10/8), no token => deny")]
45+
[DataRow("172.17.0.1", null, null, false, DisplayName = "Docker bridge IP, no token => deny")]
46+
[DataRow("203.0.113.50", null, null, false, DisplayName = "Public IPv4, no token => deny")]
47+
// --- Bootstrap token configured ---
48+
[DataRow("127.0.0.1", "secret", "secret", true, DisplayName = "Loopback + correct token => allow")]
49+
[DataRow("127.0.0.1", "secret", "wrong", false, DisplayName = "Loopback + wrong token => deny")]
50+
[DataRow("127.0.0.1", "secret", null, false, DisplayName = "Loopback + missing header => deny")]
51+
[DataRow("192.168.1.50", "secret", "secret", false, DisplayName = "Non-loopback + correct token => still deny")]
52+
[DataRow(null, "secret", null, false, DisplayName = "In-process + missing header => deny")]
53+
[DataRow(null, "secret", "secret", true, DisplayName = "In-process + correct token => allow")]
54+
public void IsConfigurationRequestAuthorized_Matrix(
55+
string remoteIp,
56+
string configuredToken,
57+
string providedHeader,
58+
bool expected)
59+
{
60+
// Arrange
61+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken);
62+
63+
DefaultHttpContext httpContext = new();
64+
httpContext.Connection.RemoteIpAddress = remoteIp is null ? null : IPAddress.Parse(remoteIp);
65+
if (providedHeader is not null)
66+
{
67+
httpContext.Request.Headers[Startup.CONFIG_AUTH_HEADER] = providedHeader;
68+
}
69+
70+
// Act
71+
bool result = Startup.IsConfigurationRequestAuthorized(httpContext);
72+
73+
// Assert
74+
Assert.AreEqual(expected, result);
75+
}
76+
77+
/// <summary>
78+
/// End-to-end test against the full middleware pipeline via TestServer. Drives both
79+
/// /configuration (v1) and /configuration/v2 with every token combination and verifies
80+
/// the HTTP status code returned by the security middleware.
81+
/// </summary>
82+
/// <param name="configurationEndpoint">The endpoint being tested.</param>
83+
/// <param name="configuredToken">Value to set DAB_CONFIG_AUTH_TOKEN to, or null to leave unset.</param>
84+
/// <param name="providedHeader">Value of the X-DAB-CONFIG-AUTH header, or null to omit.</param>
85+
/// <param name="expectedStatus">Expected HTTP status code from the POST.</param>
86+
[DataTestMethod, TestCategory(TestCategory.COSMOSDBNOSQL)]
87+
// No token configured -- backward-compatible loopback success
88+
[DataRow(CONFIGURATION_ENDPOINT, null, null, HttpStatusCode.OK)]
89+
[DataRow(CONFIGURATION_ENDPOINT_V2, null, null, HttpStatusCode.OK)]
90+
// Token configured and correct -- allowed
91+
[DataRow(CONFIGURATION_ENDPOINT, "integration-token", "integration-token", HttpStatusCode.OK)]
92+
[DataRow(CONFIGURATION_ENDPOINT_V2, "integration-token", "integration-token", HttpStatusCode.OK)]
93+
// Token configured but wrong -- forbidden
94+
[DataRow(CONFIGURATION_ENDPOINT, "correct-token", "wrong-token", HttpStatusCode.Forbidden)]
95+
[DataRow(CONFIGURATION_ENDPOINT_V2, "correct-token", "wrong-token", HttpStatusCode.Forbidden)]
96+
// Token configured but header missing -- forbidden
97+
[DataRow(CONFIGURATION_ENDPOINT, "required-token", null, HttpStatusCode.Forbidden)]
98+
[DataRow(CONFIGURATION_ENDPOINT_V2, "required-token", null, HttpStatusCode.Forbidden)]
99+
public async Task EndToEnd_PostConfiguration_StatusCodeMatrix(
100+
string configurationEndpoint,
101+
string configuredToken,
102+
string providedHeader,
103+
HttpStatusCode expectedStatus)
104+
{
105+
// Arrange
106+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, configuredToken);
107+
108+
using TestServer server = new(Program.CreateWebHostFromInMemoryUpdatableConfBuilder(Array.Empty<string>()));
109+
using HttpClient httpClient = server.CreateClient();
110+
111+
HttpRequestMessage request = new(HttpMethod.Post, configurationEndpoint)
112+
{
113+
Content = BuildPostContent(configurationEndpoint),
114+
};
115+
if (providedHeader is not null)
116+
{
117+
request.Headers.Add(Startup.CONFIG_AUTH_HEADER, providedHeader);
118+
}
119+
120+
// Act
121+
HttpResponseMessage postResult = await httpClient.SendAsync(request);
122+
123+
// Assert
124+
Assert.AreEqual(expectedStatus, postResult.StatusCode);
125+
}
126+
127+
[TestCleanup]
128+
public void Cleanup()
129+
{
130+
Environment.SetEnvironmentVariable(Startup.CONFIG_AUTH_TOKEN_ENV_VAR, null);
131+
}
132+
133+
/// <summary>
134+
/// Builds the JSON post body appropriate for the given configuration endpoint version.
135+
/// </summary>
136+
private static JsonContent BuildPostContent(string endpoint)
137+
{
138+
Config.ObjectModel.RuntimeConfig config = ReadCosmosConfigurationFromFile() with { Schema = "@env('schema')" };
139+
const string graphqlSchema = @"
140+
type Entity {
141+
id: ID!
142+
name: String!
143+
}
144+
";
145+
146+
if (endpoint == CONFIGURATION_ENDPOINT)
147+
{
148+
return JsonContent.Create(new ConfigurationPostParameters(
149+
Configuration: config.ToJson(),
150+
Schema: graphqlSchema,
151+
ConnectionString: "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
152+
AccessToken: null));
153+
}
154+
155+
if (endpoint == CONFIGURATION_ENDPOINT_V2)
156+
{
157+
return JsonContent.Create(new ConfigurationPostParametersV2(
158+
Configuration: config.ToJson(),
159+
ConfigurationOverrides: "{}",
160+
Schema: graphqlSchema,
161+
AccessToken: null));
162+
}
163+
164+
throw new ArgumentException($"Unknown endpoint: {endpoint}", nameof(endpoint));
165+
}
166+
}

src/Service/Startup.cs

Lines changed: 62 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;
@@ -89,6 +91,19 @@ public class Startup(IConfiguration configuration, ILogger<Startup> logger)
8991
public static AzureLogAnalyticsOptions AzureLogAnalyticsOptions = new();
9092
public static FileSinkOptions FileSinkOptions = new();
9193
public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect";
94+
95+
/// <summary>
96+
/// Environment variable name for the optional bootstrap token that gates access to the
97+
/// POST /configuration endpoint in late-configured (hosted) mode. When set, the request
98+
/// must include an X-DAB-CONFIG-AUTH header whose value matches this token.
99+
/// </summary>
100+
internal const string CONFIG_AUTH_TOKEN_ENV_VAR = "DAB_CONFIG_AUTH_TOKEN";
101+
102+
/// <summary>
103+
/// Header name used to supply the configuration bootstrap token.
104+
/// </summary>
105+
internal const string CONFIG_AUTH_HEADER = "X-DAB-CONFIG-AUTH";
106+
92107
private readonly HotReloadEventHandler<HotReloadEventArgs> _hotReloadEventHandler = new();
93108
private RuntimeConfigProvider? _configProvider;
94109
private ILogger<Startup> _logger = logger;
@@ -949,6 +964,10 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC
949964
{
950965
context.Response.StatusCode = StatusCodes.Status409Conflict;
951966
}
967+
else if (!IsConfigurationRequestAuthorized(context))
968+
{
969+
context.Response.StatusCode = StatusCodes.Status403Forbidden;
970+
}
952971
else
953972
{
954973
await next.Invoke();
@@ -1479,6 +1498,49 @@ private static bool IsUIEnabled(RuntimeConfig? runtimeConfig, IWebHostEnvironmen
14791498
return (runtimeConfig is not null && runtimeConfig.IsDevelopmentMode()) || env.IsDevelopment();
14801499
}
14811500

1501+
/// <summary>
1502+
/// Determines whether a POST /configuration request is authorized by enforcing:
1503+
/// 1. The request must originate from a loopback address (localhost / 127.0.0.1 / ::1).
1504+
/// 2. If the DAB_CONFIG_AUTH_TOKEN environment variable is set, the request must include
1505+
/// an X-DAB-CONFIG-AUTH header whose value matches the token (constant-time comparison).
1506+
/// This mitigates Missing Authentication for Critical Function" by preventing
1507+
/// unauthenticated network-remote callers from hijacking the runtime configuration.
1508+
/// </summary>
1509+
internal static bool IsConfigurationRequestAuthorized(HttpContext context)
1510+
{
1511+
// Defense 1: Restrict to loopback addresses only.
1512+
// A null RemoteIpAddress indicates an in-process call (e.g. TestServer)
1513+
// where there is no underlying TCP connection — by definition this cannot
1514+
// be a remote attacker, so we treat it as loopback-equivalent. A real
1515+
// network-borne request always carries a TCP source IP.
1516+
IPAddress? remoteIp = context.Connection.RemoteIpAddress;
1517+
if (remoteIp is not null && !IPAddress.IsLoopback(remoteIp))
1518+
{
1519+
return false;
1520+
}
1521+
1522+
// Defense 2: If a bootstrap token is configured, require it in the request header.
1523+
string? expectedToken = Environment.GetEnvironmentVariable(CONFIG_AUTH_TOKEN_ENV_VAR);
1524+
if (!string.IsNullOrEmpty(expectedToken))
1525+
{
1526+
string? providedToken = context.Request.Headers[CONFIG_AUTH_HEADER].FirstOrDefault();
1527+
if (string.IsNullOrEmpty(providedToken))
1528+
{
1529+
return false;
1530+
}
1531+
1532+
// Use fixed-time comparison to prevent timing attacks.
1533+
if (!CryptographicOperations.FixedTimeEquals(
1534+
Encoding.UTF8.GetBytes(expectedToken),
1535+
Encoding.UTF8.GetBytes(providedToken)))
1536+
{
1537+
return false;
1538+
}
1539+
}
1540+
1541+
return true;
1542+
}
1543+
14821544
/// <summary>
14831545
/// Checks whether On-Behalf-Of (OBO) authentication is configured by verifying that
14841546
/// the required environment variables are set and the config has user-delegated auth enabled.

0 commit comments

Comments
 (0)