Skip to content

Commit 58fefcb

Browse files
Platform agnostic port resolution for health endpoint (#2757)
## Why make this change? - Closes on #2765 To robustly determine the internal port used by the application for self-referential HTTP calls (such as health checks), especially in containerized and reverse-proxy scenarios (e.g., Azure Container Apps), without relying on the ASP.NET Core Forwarded Headers middleware. - Environment-agnostic: Works in Azure Container Apps, AKS, ACI, on-prem, and other containerized environments without requiring special middleware configuration. - Resilient: Handles a variety of deployment scenarios and ingress/proxy setups. - No security risk: Does not trust forwarded headers globally, reducing the risk of header spoofing. ## What is this change? - Refactored ResolveInternalPort() to robustly determine the correct port for internal HTTP calls by: - Parsing the ASPNETCORE_URLS environment variable for the first HTTP port, including support for wildcard bindings like http://+:1234 or http://*:8080. - Falling back to port 5000 if no valid port is found. - No longer relying on X-Forwarded-Port or Host headers, which are not trustworthy for internal routing. - Updated the health check HttpClient to always use http://localhost:<resolved-port> for internal calls, ensuring reliability across all environments. - Updated HTTPS redirection logic to exclude /health (and /graphql if needed) from redirection, allowing internal HTTP health checks to succeed without being redirected. - No changes are required to middleware configuration for this logic to work. ## How was this tested? - [x] Integration Tests - [x] Unit Tests ## Sample Request(s) `GET /health` --------- Co-authored-by: Aniruddh Munde <anmunde@microsoft.com>
1 parent dc8d6ec commit 58fefcb

3 files changed

Lines changed: 272 additions & 87 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using Azure.DataApiBuilder.Service.Utilities;
6+
using Microsoft.VisualStudio.TestTools.UnitTesting;
7+
8+
namespace Azure.DataApiBuilder.Service.Tests.UnitTests
9+
{
10+
/// <summary>
11+
/// Tests for the <see cref="PortResolutionHelper"/> class, which resolves the internal port used by the application
12+
/// </summary>
13+
[TestClass]
14+
public class PortResolutionHelperTests
15+
{
16+
/// <summary>
17+
/// Tests the <see cref="PortResolutionHelper.ResolveInternalPort"/> method to ensure it resolves the correct
18+
/// port.
19+
/// </summary>
20+
/// <remarks>This test method sets the "ASPNETCORE_URLS" environment variable to various test
21+
/// cases and verifies that the <see cref="PortResolutionHelper.ResolveInternalPort"/> method returns the
22+
/// expected port. It handles different URL formats and edge cases, including null or invalid inputs.</remarks>
23+
/// <param name="aspnetcoreUrls">A string representing the ASP.NET Core URLs to be tested.</param>
24+
/// <param name="expectedPort">The expected port number that should be resolved.</param>
25+
[DataTestMethod]
26+
[DataRow("http://localhost:5000", 5000)]
27+
[DataRow("https://localhost:443", 443)]
28+
[DataRow("http://+:1234", 1234)]
29+
[DataRow("https://*:8443", 8443)]
30+
[DataRow("http://localhost:5000;https://localhost:443", 5000)]
31+
[DataRow("https://localhost:443;http://localhost:5000", 5000)]
32+
[DataRow("http://localhost:5000,https://localhost:443", 5000)]
33+
[DataRow(null, 5000)]
34+
[DataRow("", 5000)]
35+
[DataRow("http://localhost", 80)]
36+
[DataRow("https://localhost", 443)]
37+
[DataRow("http://[::1]:5000", 5000)]
38+
[DataRow("http://localhost;https://localhost:8443", 80)]
39+
[DataRow("https://localhost:8443;https://localhost:9443", 8443)]
40+
[DataRow("invalid;http://localhost:5000", 5000)]
41+
[DataRow("http://localhost:5000;invalid", 5000)]
42+
[DataRow("http://+:", 5000)]
43+
[DataRow("https://localhost:5001;http://localhost:5000", 5000)]
44+
[DataRow("https://localhost:5001;https://localhost:5002", 5001)]
45+
public void ResolveInternalPortResolvesCorrectPortPositiveTest(string aspnetcoreUrls, int expectedPort)
46+
{
47+
TestPortResolution(aspnetcoreUrls, null, expectedPort);
48+
}
49+
50+
/// <summary>
51+
/// Tests that the <see cref="PortResolutionHelper.ResolveInternalPort"/> method uses the "DEFAULT_PORT"
52+
/// environment variable when the "ASPNETCORE_URLS" environment variable is not set.
53+
/// </summary>
54+
/// <remarks>This test sets the "DEFAULT_PORT" environment variable to "4321" and verifies that
55+
/// <see cref="PortResolutionHelper.ResolveInternalPort"/> returns this value. It ensures that the method
56+
/// correctly defaults to using "DEFAULT_PORT" when "ASPNETCORE_URLS" is null.</remarks>
57+
[TestMethod]
58+
public void ResolveInternalPortUsesDefaultPortEnvVarTest()
59+
{
60+
TestPortResolution(null, "4321", 4321);
61+
}
62+
63+
/// <summary>
64+
/// Tests that the <see cref="PortResolutionHelper.ResolveInternalPort"/> method uses the default port when the
65+
/// environment variable <c>ASPNETCORE_URLS</c> is set to invalid values.
66+
/// </summary>
67+
/// <remarks>This test sets the <c>ASPNETCORE_URLS</c> environment variable to invalid URLs and
68+
/// the <c>DEFAULT_PORT</c> environment variable to a valid port number. It verifies that <see
69+
/// cref="PortResolutionHelper.ResolveInternalPort"/> correctly falls back to using the default port specified
70+
/// by <c>DEFAULT_PORT</c>.</remarks>
71+
[TestMethod]
72+
public void ResolveInternalPortUsesDefaultPortWhenUrlsAreInvalidTest()
73+
{
74+
TestPortResolution("invalid-url;another-invalid", "4321", 4321);
75+
}
76+
77+
/// <summary>
78+
/// Tests that the <see cref="PortResolutionHelper.ResolveInternalPort"/> method falls back to the default port
79+
/// when the <c>DEFAULT_PORT</c> environment variable is set to a non-numeric value.
80+
/// </summary>
81+
/// <remarks>This test sets the <c>DEFAULT_PORT</c> environment variable to an invalid value and
82+
/// verifies that <see cref="PortResolutionHelper.ResolveInternalPort"/> correctly falls back to using
83+
/// the default port of 5000 when the <c>DEFAULT_PORT</c> cannot be parsed as a valid integer.</remarks>
84+
[TestMethod]
85+
public void ResolveInternalPortFallsBackToDefaultWhenDefaultPortIsInvalidTest()
86+
{
87+
TestPortResolution(null, "abc", 5000);
88+
}
89+
90+
/// <summary>
91+
/// Negative tests for the <see cref="PortResolutionHelper.ResolveInternalPort"/> method.
92+
/// </summary>
93+
/// <param name="aspnetcoreUrls">A string representing the ASP.NET Core URLs to be tested.</param>
94+
/// <param name="expectedPort">The expected port number that should be resolved.</param>
95+
[DataTestMethod]
96+
[DataRow("http://localhost:5000 https://localhost:443", 5000)] // space invalid, falls back to default
97+
[DataRow("http://localhost:5000|https://localhost:443", 5000)] // invalid delimiter, falls back to default
98+
[DataRow("localhost:5000", 5000)] // missing scheme: fallback to default
99+
[DataRow("http://:", 5000)] // incomplete URL: fallback to default
100+
[DataRow("ftp://localhost:21", 5000)] // unsupported scheme: fallback to default
101+
[DataRow("http://unix:/var/run/app.sock", 80)] // unix socket: defaults to 80 (no port specified)
102+
[DataRow("http://unix:var/run/app.sock", 5000)] // malformed unix socket: fallback to default
103+
[DataRow("http://unix:", 80)] // incomplete unix socket: defaults to 80
104+
public void ResolveInternalPortResolvesCorrectPortNegativeTest(string aspnetcoreUrls, int expectedPort)
105+
{
106+
TestPortResolution(aspnetcoreUrls, null, expectedPort);
107+
}
108+
109+
/// <summary>
110+
/// Helper method to test port resolution with environment variables.
111+
/// </summary>
112+
/// <param name="aspnetcoreUrls">The ASPNETCORE_URLS environment variable value to set.</param>
113+
/// <param name="defaultPort">The DEFAULT_PORT environment variable value to set.</param>
114+
/// <param name="expectedPort">The expected port number that should be resolved.</param>
115+
private static void TestPortResolution(string aspnetcoreUrls, string defaultPort, int expectedPort)
116+
{
117+
string originalUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
118+
string originalDefaultPort = Environment.GetEnvironmentVariable("DEFAULT_PORT");
119+
Environment.SetEnvironmentVariable("ASPNETCORE_URLS", aspnetcoreUrls);
120+
Environment.SetEnvironmentVariable("DEFAULT_PORT", defaultPort);
121+
try
122+
{
123+
int port = PortResolutionHelper.ResolveInternalPort();
124+
Assert.AreEqual(expectedPort, port);
125+
}
126+
finally
127+
{
128+
Environment.SetEnvironmentVariable("ASPNETCORE_URLS", originalUrls);
129+
Environment.SetEnvironmentVariable("DEFAULT_PORT", originalDefaultPort);
130+
}
131+
}
132+
}
133+
}

src/Service/Startup.cs

Lines changed: 22 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
using Azure.DataApiBuilder.Service.Exceptions;
2929
using Azure.DataApiBuilder.Service.HealthCheck;
3030
using Azure.DataApiBuilder.Service.Telemetry;
31+
using Azure.DataApiBuilder.Service.Utilities;
3132
using HotChocolate;
3233
using HotChocolate.AspNetCore;
3334
using HotChocolate.Execution;
@@ -49,7 +50,6 @@
4950
using Microsoft.Extensions.Hosting;
5051
using Microsoft.Extensions.Logging;
5152
using Microsoft.Extensions.Options;
52-
using Microsoft.Extensions.Primitives;
5353
using NodaTime;
5454
using OpenTelemetry.Exporter;
5555
using OpenTelemetry.Logs;
@@ -284,25 +284,9 @@ public void ConfigureServices(IServiceCollection services)
284284
services.AddHttpClient("ContextConfiguredHealthCheckClient")
285285
.ConfigureHttpClient((serviceProvider, client) =>
286286
{
287-
IHttpContextAccessor httpCtxAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>();
288-
HttpContext? httpContext = httpCtxAccessor.HttpContext;
289-
string baseUri = string.Empty;
290-
291-
if (httpContext is not null)
292-
{
293-
string scheme = httpContext.Request.Scheme; // "http" or "https"
294-
string host = httpContext.Request.Host.Host ?? "localhost"; // e.g. "localhost"
295-
int port = ResolveInternalPort(httpContext);
296-
baseUri = $"{scheme}://{host}:{port}";
297-
client.BaseAddress = new Uri(baseUri);
298-
}
299-
else
300-
{
301-
// Optional fallback if ever needed in non-request scenarios
302-
baseUri = $"http://localhost:{ResolveInternalPort()}";
303-
client.BaseAddress = new Uri(baseUri);
304-
}
305-
287+
int port = PortResolutionHelper.ResolveInternalPort();
288+
string baseUri = $"http://localhost:{port}";
289+
client.BaseAddress = new Uri(baseUri);
306290
_logger.LogInformation($"Configured HealthCheck HttpClient BaseAddress as: {baseUri}");
307291
client.DefaultRequestHeaders.Accept.Clear();
308292
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -472,21 +456,21 @@ private void AddGraphQLService(IServiceCollection services, GraphQLRuntimeOption
472456
}
473457

474458
server.AddErrorFilter(error =>
459+
{
460+
if (error.Exception is not null)
475461
{
476-
if (error.Exception is not null)
477-
{
478-
_logger.LogError(exception: error.Exception, message: "A GraphQL request execution error occurred.");
479-
return error.WithMessage(error.Exception.Message);
480-
}
462+
_logger.LogError(exception: error.Exception, message: "A GraphQL request execution error occurred.");
463+
return error.WithMessage(error.Exception.Message);
464+
}
481465

482-
if (error.Code is not null)
483-
{
484-
_logger.LogError(message: "Error code: {errorCode}\nError message: {errorMessage}", error.Code, error.Message);
485-
return error.WithMessage(error.Message);
486-
}
466+
if (error.Code is not null)
467+
{
468+
_logger.LogError(message: "Error code: {errorCode}\nError message: {errorMessage}", error.Code, error.Message);
469+
return error.WithMessage(error.Message);
470+
}
487471

488-
return error;
489-
})
472+
return error;
473+
})
490474
.AddErrorFilter(error =>
491475
{
492476
if (error.Exception is DataApiBuilderException thrownException)
@@ -565,7 +549,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC
565549

566550
if (!Program.IsHttpsRedirectionDisabled)
567551
{
568-
app.UseHttpsRedirection();
552+
// Use HTTPS redirection for all endpoints except /health and /graphql.
553+
// This is necessary because ContextConfiguredHealthCheckClient base URI is http://localhost:{port} for internal API calls
554+
app.UseWhen(
555+
context => !(context.Request.Path.StartsWithSegments("/health") || context.Request.Path.StartsWithSegments("/graphql")),
556+
appBuilder => appBuilder.UseHttpsRedirection()
557+
);
569558
}
570559

571560
// URL Rewrite middleware MUST be called prior to UseRouting().
@@ -1055,59 +1044,5 @@ public static void AddValidFilters()
10551044
LoggerFilters.AddFilter(typeof(IAuthorizationResolver).FullName);
10561045
LoggerFilters.AddFilter("default");
10571046
}
1058-
1059-
/// <summary>
1060-
/// Get the internal port of the container.
1061-
/// </summary>
1062-
/// <param name="httpContext">The HttpContext</param>
1063-
/// <returns>The internal container port</returns>
1064-
private static int ResolveInternalPort(HttpContext? httpContext = null)
1065-
{
1066-
// Try X-Forwarded-Port if context is present
1067-
if (httpContext is not null &&
1068-
httpContext.Request.Headers.TryGetValue("X-Forwarded-Port", out StringValues fwdPortVal) &&
1069-
int.TryParse(fwdPortVal.ToString(), out int fwdPort) &&
1070-
fwdPort > 0)
1071-
{
1072-
return fwdPort;
1073-
}
1074-
1075-
// Infer scheme from context if available, else default to "http"
1076-
string scheme = httpContext?.Request.Scheme ?? "http";
1077-
1078-
// Check ASPNETCORE_URLS env var
1079-
string? aspnetcoreUrls = Environment.GetEnvironmentVariable("ASPNETCORE_URLS");
1080-
1081-
if (!string.IsNullOrWhiteSpace(aspnetcoreUrls))
1082-
{
1083-
foreach (string part in aspnetcoreUrls.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries))
1084-
{
1085-
string trimmed = part.Trim();
1086-
1087-
// Handle wildcard format (e.g. http://+:5002)
1088-
if (trimmed.StartsWith($"{scheme}://+:", StringComparison.OrdinalIgnoreCase))
1089-
{
1090-
int colonIndex = trimmed.LastIndexOf(':');
1091-
if (colonIndex != -1 &&
1092-
int.TryParse(trimmed.Substring(colonIndex + 1), out int wildcardPort) &&
1093-
wildcardPort > 0)
1094-
{
1095-
return wildcardPort;
1096-
}
1097-
}
1098-
1099-
// Handle standard URI format
1100-
if (trimmed.StartsWith($"{scheme}://", StringComparison.OrdinalIgnoreCase) &&
1101-
Uri.TryCreate(trimmed, UriKind.Absolute, out Uri? uri))
1102-
{
1103-
return uri.Port;
1104-
}
1105-
}
1106-
}
1107-
1108-
// Fallback
1109-
return scheme.Equals("https", StringComparison.OrdinalIgnoreCase) ? 443 : 5000;
1110-
}
1111-
11121047
}
11131048
}

0 commit comments

Comments
 (0)