diff --git a/src/RedShirt.Example.Api/Extensions/ForwardingHeadersExtensions.cs b/src/RedShirt.Example.Api/Extensions/ForwardingHeadersExtensions.cs
new file mode 100644
index 0000000..46e1ab7
--- /dev/null
+++ b/src/RedShirt.Example.Api/Extensions/ForwardingHeadersExtensions.cs
@@ -0,0 +1,90 @@
+using Microsoft.AspNetCore.HttpOverrides;
+using IPNetwork = System.Net.IPNetwork;
+
+namespace RedShirt.Example.Api.Extensions;
+
+public static class ForwardingHeadersExtensions
+{
+ ///
+ /// Parses a CIDR string (for example 172.16.0.0/12) into an .
+ ///
+ /// CIDR string
+ /// The parsed network when successful.
+ /// true if successfully parsed, otherwise false.
+ private static bool TryParseCidrAddressToNetwork(this string input, out IPNetwork network)
+ {
+ if (!string.IsNullOrWhiteSpace(input))
+ {
+ return IPNetwork.TryParse(input.AsSpan().Trim(), out network);
+ }
+
+ network = default;
+ return false;
+ }
+
+ private static ConfigurationModel GetConfigurationModel(IConfiguration configuration)
+ {
+ return configuration
+ .GetSection("ForwardingHeaders")
+ .Get() ?? new ConfigurationModel
+ {
+ RespectHeaders = false,
+ TrustedNetworks = []
+ };
+ }
+
+ public static IServiceCollection ConsiderConfiguringForwardingHeaders(this IServiceCollection serviceCollection,
+ IConfigurationRoot configuration)
+ {
+ var configurationModel = GetConfigurationModel(configuration);
+
+ if (!configurationModel.RespectHeaders)
+ {
+ // Not enabled, immediately return
+ return serviceCollection;
+ }
+
+ return serviceCollection.Configure(options =>
+ {
+ options.ForwardedHeaders =
+ ForwardedHeaders.XForwardedFor
+ | ForwardedHeaders.XForwardedProto
+ | ForwardedHeaders.XForwardedHost
+ | ForwardedHeaders.XForwardedPrefix;
+
+ // TCP peer must be a trusted proxy for forwarding headers, so add range to known network.
+ // A known network is a CIDR whose TCP peers ASP.NET treats as trusted proxies for X-Forwarded-* headers.
+ // Example for Cloudflare proxy: https://www.cloudflare.com/ips/
+
+ // Remove the default loopback-only network allowlist so the next lines fully define trust.
+ options.KnownIPNetworks.Clear();
+ // Remove the default loopback proxy IPs so they do not mix with a custom proxy CIDR.
+ options.KnownProxies.Clear();
+
+ // Docker user-defined bridges usually sit in 172.16.0.0/12, `proxy`.
+ foreach (var candidateString in configurationModel.TrustedNetworks)
+ {
+ if (candidateString.TryParseCidrAddressToNetwork(out var network))
+ {
+ options.KnownIPNetworks.Add(network);
+ }
+ }
+ });
+ }
+
+ public static IApplicationBuilder ConsiderUsingForwardedHeaders(this WebApplication app)
+ {
+ if (GetConfigurationModel(app.Configuration).RespectHeaders)
+ {
+ app.UseForwardedHeaders();
+ }
+
+ return app;
+ }
+
+ private sealed class ConfigurationModel
+ {
+ public required bool RespectHeaders { get; init; }
+ public required List TrustedNetworks { get; init; }
+ }
+}
\ No newline at end of file
diff --git a/src/RedShirt.Example.Api/Extensions/HttpRequestExceptions.cs b/src/RedShirt.Example.Api/Extensions/HttpRequestExceptions.cs
new file mode 100644
index 0000000..e79149c
--- /dev/null
+++ b/src/RedShirt.Example.Api/Extensions/HttpRequestExceptions.cs
@@ -0,0 +1,22 @@
+namespace RedShirt.Example.Api.Extensions;
+
+public static class HttpRequestExceptions
+{
+ public static Uri GetPublicBaseUri(this HttpRequest request)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ var builder = new UriBuilder
+ {
+ Scheme = request.Scheme,
+ Host = request.Host.Host,
+ Port = request.Host.Port ?? -1,
+ Path = request.PathBase.HasValue ? request.PathBase.Value! : "/"
+ };
+ if (builder.Port is 80 or 443)
+ {
+ builder.Port = -1;
+ }
+
+ return builder.Uri;
+ }
+}
\ No newline at end of file
diff --git a/src/RedShirt.Example.Api/Extensions/WebApplicationExtensions.cs b/src/RedShirt.Example.Api/Extensions/WebApplicationExtensions.cs
deleted file mode 100644
index 3f35787..0000000
--- a/src/RedShirt.Example.Api/Extensions/WebApplicationExtensions.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-namespace RedShirt.Example.Api.Extensions;
-
-internal static class WebApplicationExtensions
-{
- public static WebApplication ConsiderAddingPathBase(this WebApplication app)
- {
- var pathBase = app.Configuration.GetSection("API").Get()?.PathBase;
-
- if (!string.IsNullOrWhiteSpace(pathBase))
- {
- app.UsePathBase(pathBase);
- }
-
- return app;
- }
-
- private sealed class ConfigurationModel
- {
- public required string? PathBase { get; init; }
- }
-}
\ No newline at end of file
diff --git a/src/RedShirt.Example.Api/Program.cs b/src/RedShirt.Example.Api/Program.cs
index b61747d..0d4e80a 100644
--- a/src/RedShirt.Example.Api/Program.cs
+++ b/src/RedShirt.Example.Api/Program.cs
@@ -38,13 +38,13 @@
builder.Services
.AddApiSwaggerDocument(builder.Configuration)
.ConfigureApiServices(builder.Configuration)
+ .ConsiderConfiguringForwardingHeaders(builder.Configuration)
.AddControllersWithViews();
var app = builder.Build();
app
- // If we add a path base, then it must be the first middleware that we set.
- .ConsiderAddingPathBase()
+ .ConsiderUsingForwardedHeaders()
.UseExceptionHandler();
if (app.Environment.IsDevelopment())
@@ -70,6 +70,7 @@
app.UseRouting();
app.ConsiderUsingAuthentication();
+// Always set UseAuthorization because requirements aren't guaranteed to be based on authenticated identity.
app.UseAuthorization();
if (Environment.GetEnvironmentVariable("NSWAG_RUN") != "1")
diff --git a/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/ForwardingHeadersExtensionsTests.cs b/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/ForwardingHeadersExtensionsTests.cs
new file mode 100644
index 0000000..a17a50a
--- /dev/null
+++ b/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/ForwardingHeadersExtensionsTests.cs
@@ -0,0 +1,181 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using RedShirt.Example.Api.Extensions;
+using System.Net;
+using IPNetwork = System.Net.IPNetwork;
+
+namespace RedShirt.Example.Api.UnitTests.Tests.Extensions;
+
+public class ForwardingHeadersExtensionsTests
+{
+ private const string ConfigurationSectionName = "ForwardingHeaders";
+
+ private static IConfigurationRoot CreateConfiguration(
+ bool? respectHeaders = null,
+ params string[] trustedNetworks)
+ {
+ var values = new Dictionary();
+ if (respectHeaders is not null)
+ {
+ values[$"{ConfigurationSectionName}:RespectHeaders"] = respectHeaders.Value.ToString();
+ }
+
+ for (var index = 0; index < trustedNetworks.Length; index++)
+ {
+ values[$"{ConfigurationSectionName}:TrustedNetworks:{index}"] = trustedNetworks[index];
+ }
+
+ return new ConfigurationBuilder()
+ .AddInMemoryCollection(values)
+ .Build();
+ }
+
+ [Fact]
+ public void ConsiderConfiguringForwardingHeaders_ReturnsSameServiceCollection()
+ {
+ var services = new ServiceCollection();
+ var configuration = CreateConfiguration(respectHeaders: true, "10.0.0.0/8");
+
+ var result = services.ConsiderConfiguringForwardingHeaders(configuration);
+
+ Assert.Same(services, result);
+ }
+
+ [Fact]
+ public void ConsiderConfiguringForwardingHeaders_DoesNotRegisterOptionsWhenDisabled()
+ {
+ var services = new ServiceCollection();
+ var configuration = CreateConfiguration(respectHeaders: false);
+
+ services.ConsiderConfiguringForwardingHeaders(configuration);
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.Null(provider.GetService>());
+ }
+
+ [Fact]
+ public void ConsiderConfiguringForwardingHeaders_ConfiguresOptionsWhenEnabled()
+ {
+ var services = new ServiceCollection();
+ var configuration = CreateConfiguration(
+ respectHeaders: true,
+ "10.0.0.0/8",
+ "not-a-cidr",
+ " 172.16.0.0/12 ",
+ "2001:db8::/32");
+
+ services.ConsiderConfiguringForwardingHeaders(configuration);
+
+ using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.Equal(
+ ForwardedHeaders.XForwardedFor
+ | ForwardedHeaders.XForwardedProto
+ | ForwardedHeaders.XForwardedHost
+ | ForwardedHeaders.XForwardedPrefix,
+ options.ForwardedHeaders);
+ Assert.Empty(options.KnownProxies);
+ Assert.Equal(3, options.KnownIPNetworks.Count);
+ Assert.Contains(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8), options.KnownIPNetworks);
+ Assert.Contains(new IPNetwork(IPAddress.Parse("172.16.0.0"), 12), options.KnownIPNetworks);
+ Assert.Contains(new IPNetwork(IPAddress.Parse("2001:db8::"), 32), options.KnownIPNetworks);
+ }
+
+ [Fact]
+ public void ConsiderConfiguringForwardingHeaders_UsesDefaultsWhenSectionMissing()
+ {
+ var services = new ServiceCollection();
+ var configuration = new ConfigurationBuilder().Build();
+
+ services.ConsiderConfiguringForwardingHeaders(configuration);
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.Null(provider.GetService>());
+ }
+
+ [Fact]
+ public void ConsiderUsingForwardedHeaders_ReturnsSameApplicationInstance()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ var app = builder.Build();
+
+ var result = app.ConsiderUsingForwardedHeaders();
+
+ Assert.Same(app, result);
+ }
+
+ [Fact]
+ public async Task ConsiderUsingForwardedHeaders_DoesNotApplyHeadersWhenDisabled()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Configuration.AddInMemoryCollection(CreateConfiguration(respectHeaders: false)
+ .AsEnumerable()
+ .ToDictionary(entry => entry.Key, entry => entry.Value));
+ builder.Services.AddRouting();
+
+ string? capturedScheme = null;
+
+ await using var app = builder.Build();
+ app.ConsiderUsingForwardedHeaders();
+ app.UseRouting();
+ app.MapGet("/", (HttpRequest request) =>
+ {
+ capturedScheme = request.Scheme;
+ return Results.NoContent();
+ });
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ var client = app.GetTestClient();
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/");
+ request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
+
+ var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode);
+ Assert.Equal("http", capturedScheme);
+ }
+
+ [Fact]
+ public async Task ConsiderUsingForwardedHeaders_AppliesHeadersWhenEnabled()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Configuration.AddInMemoryCollection(CreateConfiguration(respectHeaders: true, "0.0.0.0/0")
+ .AsEnumerable()
+ .ToDictionary(entry => entry.Key, entry => entry.Value));
+ builder.Services
+ .ConsiderConfiguringForwardingHeaders((IConfigurationRoot)builder.Configuration)
+ .AddRouting();
+
+ string? capturedScheme = null;
+
+ await using var app = builder.Build();
+ app.ConsiderUsingForwardedHeaders();
+ app.UseRouting();
+ app.MapGet("/", (HttpRequest request) =>
+ {
+ capturedScheme = request.Scheme;
+ return Results.NoContent();
+ });
+
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ var client = app.GetTestClient();
+ using var request = new HttpRequestMessage(HttpMethod.Get, "/");
+ request.Headers.TryAddWithoutValidation("X-Forwarded-Proto", "https");
+
+ var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode);
+ Assert.Equal("https", capturedScheme);
+ }
+}
diff --git a/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/HttpRequestExceptionsTests.cs b/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/HttpRequestExceptionsTests.cs
new file mode 100644
index 0000000..77c1fcc
--- /dev/null
+++ b/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/HttpRequestExceptionsTests.cs
@@ -0,0 +1,54 @@
+using Microsoft.AspNetCore.Http;
+using RedShirt.Example.Api.Extensions;
+
+namespace RedShirt.Example.Api.UnitTests.Tests.Extensions;
+
+public class HttpRequestExceptionsTests
+{
+ private static HttpRequest CreateRequest(
+ string scheme,
+ string host,
+ int? port = null,
+ string pathBase = "")
+ {
+ var context = new DefaultHttpContext
+ {
+ Request =
+ {
+ Scheme = scheme,
+ Host = port is null ? new HostString(host) : new HostString(host, port.Value),
+ PathBase = new PathString(pathBase),
+ },
+ };
+
+ return context.Request;
+ }
+
+ [Fact]
+ public void GetPublicBaseUri_ThrowsWhenRequestIsNull()
+ {
+ HttpRequest? request = null;
+
+ Assert.Throws(() => request!.GetPublicBaseUri());
+ }
+
+ [Theory]
+ [InlineData("https", "example.com", null, "", "https://example.com/")]
+ [InlineData("http", "example.com", null, "", "http://example.com/")]
+ [InlineData("https", "example.com", 443, "", "https://example.com/")]
+ [InlineData("http", "example.com", 80, "", "http://example.com/")]
+ [InlineData("https", "example.com", 8443, "", "https://example.com:8443/")]
+ [InlineData("http", "example.com", 8080, "", "http://example.com:8080/")]
+ [InlineData("https", "example.com", null, "/api", "https://example.com/api")]
+ public void GetPublicBaseUri_BuildsUriFromSchemeHostPortAndPathBase(
+ string scheme,
+ string host,
+ int? port,
+ string pathBase,
+ string expected)
+ {
+ var uri = CreateRequest(scheme, host, port, pathBase).GetPublicBaseUri();
+
+ Assert.Equal(expected, uri.ToString());
+ }
+}
diff --git a/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/WebApplicationExtensionsTests.cs b/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/WebApplicationExtensionsTests.cs
deleted file mode 100644
index 0809dab..0000000
--- a/test/RedShirt.Example.Api.UnitTests/Tests/Extensions/WebApplicationExtensionsTests.cs
+++ /dev/null
@@ -1,131 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.TestHost;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.DependencyInjection;
-using RedShirt.Example.Api.Extensions;
-
-namespace RedShirt.Example.Api.UnitTests.Tests.Extensions;
-
-public class WebApplicationExtensionsTests
-{
- public class ConsiderAddingPathBase
- {
- private static Dictionary ConfigurationWithPathBase(string? pathBase)
- {
- var values = new Dictionary();
- if (pathBase is not null)
- {
- values["API:PathBase"] = pathBase;
- }
-
- return values;
- }
-
- [Fact]
- public async Task DoesNotSetPathBase_WhenApiSectionMissing()
- {
- var builder = WebApplication.CreateBuilder();
- builder.WebHost.UseTestServer();
- builder.Services.AddRouting();
-
- string? capturedPathBase = null;
-
- await using var app = builder.Build();
- app.ConsiderAddingPathBase();
- app.UseRouting();
- app.MapGet("/ping", (HttpContext context) =>
- {
- capturedPathBase = context.Request.PathBase.Value;
- return Results.NoContent();
- });
-
- await app.StartAsync(TestContext.Current.CancellationToken);
- var client = app.GetTestClient();
- var response = await client.GetAsync(new Uri("/ping", UriKind.Relative),
- TestContext.Current.CancellationToken);
-
- Assert.Equal(StatusCodes.Status204NoContent, (int) response.StatusCode);
- Assert.True(string.IsNullOrEmpty(capturedPathBase));
- }
-
- [Theory]
- [InlineData(null)]
- [InlineData("")]
- [InlineData(" ")]
- public async Task DoesNotSetPathBase_WhenPathBaseMissingOrWhitespace(string? pathBase)
- {
- var builder = WebApplication.CreateBuilder();
- builder.WebHost.UseTestServer();
- builder.Configuration.AddInMemoryCollection(ConfigurationWithPathBase(pathBase));
- builder.Services.AddRouting();
-
- string? capturedPathBase = null;
- string? capturedPath = null;
-
- await using var app = builder.Build();
- app.ConsiderAddingPathBase();
- app.UseRouting();
- app.MapGet("/ping", (HttpContext context) =>
- {
- capturedPathBase = context.Request.PathBase.Value;
- capturedPath = context.Request.Path.Value;
- return Results.NoContent();
- });
-
- await app.StartAsync(TestContext.Current.CancellationToken);
- var client = app.GetTestClient();
- var response = await client.GetAsync(new Uri("/ping", UriKind.Relative),
- TestContext.Current.CancellationToken);
-
- Assert.Equal(StatusCodes.Status204NoContent, (int) response.StatusCode);
- Assert.True(string.IsNullOrEmpty(capturedPathBase));
- Assert.Equal("/ping", capturedPath);
- }
-
- [Fact]
- public void ReturnsSameApplicationInstance()
- {
- var builder = WebApplication.CreateBuilder();
- builder.WebHost.UseTestServer();
- var app = builder.Build();
-
- var result = app.ConsiderAddingPathBase();
-
- Assert.Same(app, result);
- }
-
- [Fact]
- public async Task SetsPathBase_WhenConfigured()
- {
- const string pathBase = "/example";
-
- var builder = WebApplication.CreateBuilder();
- builder.WebHost.UseTestServer();
- builder.Configuration.AddInMemoryCollection(ConfigurationWithPathBase(pathBase));
- builder.Services.AddRouting();
-
- string? capturedPathBase = null;
- string? capturedPath = null;
-
- await using var app = builder.Build();
- app.ConsiderAddingPathBase();
- app.UseRouting();
- app.MapGet("/ping", (HttpContext context) =>
- {
- capturedPathBase = context.Request.PathBase.Value;
- capturedPath = context.Request.Path.Value;
- return Results.NoContent();
- });
-
- await app.StartAsync(TestContext.Current.CancellationToken);
- var client = app.GetTestClient();
- var response = await client.GetAsync(new Uri($"{pathBase}/ping", UriKind.Relative),
- TestContext.Current.CancellationToken);
-
- Assert.Equal(StatusCodes.Status204NoContent, (int) response.StatusCode);
- Assert.Equal(pathBase, capturedPathBase);
- Assert.Equal("/ping", capturedPath);
- }
- }
-}
\ No newline at end of file
diff --git a/test/local/docker-compose.yaml b/test/local/docker-compose.yaml
index 8d57d92..c7549dc 100644
--- a/test/local/docker-compose.yaml
+++ b/test/local/docker-compose.yaml
@@ -97,7 +97,11 @@ services:
##
ASPNETCORE_ENVIRONMENT: Development # Enable swagger
ASPNETCORE_URLS: http://*:8080
- API__PATH_BASE: null
+ # Forwarding headers (X-Forwarded-* from trusted reverse proxies in front of Kestrel)
+ ##
+ FORWARDING_HEADERS__RESPECT_HEADERS: "${FORWARDING_HEADERS__RESPECT_HEADERS-false}"
+ # Docker user-defined bridge networks (typical compose / `proxy` network peers). Add more CIDRs as __1, __2, ...
+ FORWARDING_HEADERS__TRUSTED_NETWORKS__0: "${FORWARDING_HEADERS__TRUSTED_NETWORKS__0-172.16.0.0/12}"
# AWS Settings
##
AWS_SERVICE_URL: http://ministack:4566