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
90 changes: 90 additions & 0 deletions src/RedShirt.Example.Api/Extensions/ForwardingHeadersExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.HttpOverrides;
using IPNetwork = System.Net.IPNetwork;

namespace RedShirt.Example.Api.Extensions;

public static class ForwardingHeadersExtensions
{
/// <summary>
/// Parses a CIDR string (for example <c>172.16.0.0/12</c>) into an <see cref="IPNetwork" />.
/// </summary>
/// <param name="input">CIDR string</param>
/// <param name="network">The parsed network when successful.</param>
/// <returns><c>true</c> if successfully parsed, otherwise <c>false</c>.</returns>
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<ConfigurationModel>() ?? 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<ForwardedHeadersOptions>(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<string> TrustedNetworks { get; init; }
}
}
22 changes: 22 additions & 0 deletions src/RedShirt.Example.Api/Extensions/HttpRequestExceptions.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
21 changes: 0 additions & 21 deletions src/RedShirt.Example.Api/Extensions/WebApplicationExtensions.cs

This file was deleted.

5 changes: 3 additions & 2 deletions src/RedShirt.Example.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string?>();
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<IOptions<ForwardedHeadersOptions>>());
}

[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<IOptions<ForwardedHeadersOptions>>().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<IOptions<ForwardedHeadersOptions>>());
}

[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);
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentNullException>(() => 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());
}
}
Loading