Skip to content

Commit 8f51fff

Browse files
committed
Support disabling forwarded headers and cover proxy boundaries
1 parent 3f102a2 commit 8f51fff

3 files changed

Lines changed: 76 additions & 13 deletions

File tree

‎docs/forwarded-headers.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ This changes the previous trust-all behavior. Before upgrading a deployment behi
1919
2020
Direct self-hosted installations do not need a proxy or additional configuration. `ForwardLimit: 1` is the maximum number of forwarded entries processed, not a minimum number of proxies required on each request. Requests without forwarded headers retain their connection IP and scheme. Multiple trusted proxies require a limit matching the intended chain and trust configuration for each traversed proxy.
2121

22-
The current application configuration requires a positive integer. An explicit zero or negative limit prevents the middleware from initializing. This is stricter than ASP.NET Core itself: zero processes no forwarded entries, and a nullable limit can allow unlimited depth. This application does not currently expose the unlimited option. Supporting an explicit disabled mode or unlimited depth should be a separate, tested configuration decision; unlimited depth still requires restricted trusted peers.
22+
`ForwardLimit` defaults to `1`. Set it to `0` to disable forwarded-header processing, including headers from trusted proxies; requests retain the connection IP and scheme. Negative values are invalid and prevent the middleware from initializing. A positive value caps the number of entries processed, with processing also stopping at an unknown peer. This application requires a finite integer and does not expose ASP.NET Core's nullable/unlimited option. Direct self-hosters can leave the default unchanged or explicitly disable processing; deployments relying on a TLS-terminating proxy must keep processing enabled and configure their trusted peers.
2323

2424
Forwarded headers run after exception/status-code handling and before HTTPS redirection, request logging, routing, MCP origin validation, authentication, and throttling. This lets downstream middleware use the validated client IP and scheme. Microsoft explicitly permits placement after diagnostics and error handling. See [Microsoft's proxy configuration guidance](https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-10.0).
2525

‎src/Exceptionless.Web/Extensions/ForwardedHeadersConfiguration.cs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ public static void Configure(ForwardedHeadersOptions options, IConfiguration con
1010
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
1111
options.RequireHeaderSymmetry = false;
1212
options.ForwardLimit = configuration.GetValue("ForwardedHeaders:ForwardLimit", 1);
13-
if (options.ForwardLimit is < 1)
14-
throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be positive.");
13+
if (options.ForwardLimit is < 0)
14+
throw new InvalidOperationException("ForwardedHeaders:ForwardLimit must be zero or positive.");
1515

1616
// Retain the framework's loopback defaults and add explicitly trusted ingress peers.
1717
foreach (string address in configuration.GetSection("ForwardedHeaders:KnownProxies").Get<string[]>() ?? [])

‎tests/Exceptionless.Tests/Extensions/ForwardedHeadersConfigurationTests.cs‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@ namespace Exceptionless.Tests.Extensions;
1212
public sealed class ForwardedHeadersConfigurationTests
1313
{
1414
[Theory]
15-
[InlineData(false, "198.51.100.20")]
16-
[InlineData(true, "203.0.113.40")]
17-
public async Task Invoke_ForwardedAddress_OnlyUsesConfiguredPeer(bool trusted, string expected)
15+
[InlineData("192.0.2.10", "1", "198.51.100.20", "http")]
16+
[InlineData("198.51.100.20", "1", "203.0.113.40", "https")]
17+
[InlineData("198.51.100.20", "0", "198.51.100.20", "http")]
18+
public async Task Invoke_ForwardedAddress_RespectsConfiguredPeerAndLimit(string trustedPeer, string limit, string expectedAddress, string expectedScheme)
1819
{
20+
// Arrange
1921
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
2022
{
21-
["ForwardedHeaders:KnownProxies:0"] = trusted ? "198.51.100.20" : "192.0.2.10"
23+
["ForwardedHeaders:KnownProxies:0"] = trustedPeer,
24+
["ForwardedHeaders:ForwardLimit"] = limit
2225
}).Build();
2326
var options = new ForwardedHeadersOptions();
2427
ForwardedHeadersConfiguration.Configure(options, configuration);
@@ -28,46 +31,106 @@ public async Task Invoke_ForwardedAddress_OnlyUsesConfiguredPeer(bool trusted, s
2831
context.Request.Headers["X-Forwarded-For"] = "203.0.113.40";
2932
context.Request.Headers["X-Forwarded-Proto"] = "https";
3033
var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance, Options.Create(options));
34+
35+
// Act
3136
await middleware.Invoke(context);
32-
Assert.Equal(expected, context.Request.GetClientIpAddress());
33-
Assert.Equal(trusted ? "https" : "http", context.Request.Scheme);
37+
38+
// Assert
39+
Assert.Equal(expectedAddress, context.Request.GetClientIpAddress());
40+
Assert.Equal(expectedScheme, context.Request.Scheme);
3441
}
3542

36-
[Fact]
37-
public async Task Invoke_ForwardedChain_UsesConfiguredDepthAndStopsAtUnknownPeer()
43+
[Theory]
44+
[InlineData("1", "192.0.2.11")]
45+
[InlineData("2", "203.0.113.40")]
46+
[InlineData("3", "203.0.113.40")]
47+
public async Task Invoke_ForwardedChain_UsesConfiguredDepthAndStopsAtUnknownPeer(string limit, string expectedAddress)
3848
{
49+
// Arrange
3950
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
4051
{
4152
["ForwardedHeaders:KnownNetworks:0"] = "192.0.2.0/24",
42-
["ForwardedHeaders:ForwardLimit"] = "2"
53+
["ForwardedHeaders:ForwardLimit"] = limit
4354
}).Build();
4455
var options = new ForwardedHeadersOptions();
4556
ForwardedHeadersConfiguration.Configure(options, configuration);
4657
var context = new DefaultHttpContext();
4758
context.Connection.RemoteIpAddress = IPAddress.Parse("192.0.2.10");
4859
context.Request.Headers["X-Forwarded-For"] = "198.51.100.1, 203.0.113.40, 192.0.2.11";
4960
var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance, Options.Create(options));
61+
62+
// Act
63+
await middleware.Invoke(context);
64+
65+
// Assert
66+
Assert.Equal(expectedAddress, context.Request.GetClientIpAddress());
67+
}
68+
69+
[Theory]
70+
[InlineData("http")]
71+
[InlineData("https")]
72+
public async Task Invoke_DirectRequest_PreservesConnectionAddressAndScheme(string scheme)
73+
{
74+
// Arrange
75+
var options = new ForwardedHeadersOptions();
76+
ForwardedHeadersConfiguration.Configure(options, new ConfigurationBuilder().Build());
77+
var context = new DefaultHttpContext();
78+
context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.40");
79+
context.Request.Scheme = scheme;
80+
var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance, Options.Create(options));
81+
82+
// Act
5083
await middleware.Invoke(context);
84+
85+
// Assert
5186
Assert.Equal("203.0.113.40", context.Request.GetClientIpAddress());
87+
Assert.Equal(scheme, context.Request.Scheme);
88+
}
89+
90+
[Fact]
91+
public void Configure_NegativeForwardLimit_Throws()
92+
{
93+
// Arrange
94+
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
95+
{
96+
["ForwardedHeaders:ForwardLimit"] = "-1"
97+
}).Build();
98+
99+
// Act
100+
var exception = Assert.Throws<InvalidOperationException>(() => ForwardedHeadersConfiguration.Configure(new ForwardedHeadersOptions(), configuration));
101+
102+
// Assert
103+
Assert.Contains("ForwardedHeaders:ForwardLimit", exception.Message, StringComparison.Ordinal);
52104
}
53105

54106
[Theory]
55107
[InlineData("0.0.0.0/0")]
56108
[InlineData("::/0")]
57109
public void Configure_UnboundedTrustedNetwork_Throws(string network)
58110
{
111+
// Arrange
59112
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
60113
{
61114
["ForwardedHeaders:KnownNetworks:0"] = network
62115
}).Build();
63-
Assert.Throws<InvalidOperationException>(() => ForwardedHeadersConfiguration.Configure(new ForwardedHeadersOptions(), configuration));
116+
117+
// Act
118+
var exception = Assert.Throws<InvalidOperationException>(() => ForwardedHeadersConfiguration.Configure(new ForwardedHeadersOptions(), configuration));
119+
120+
// Assert
121+
Assert.Contains("ForwardedHeaders:KnownNetworks", exception.Message, StringComparison.Ordinal);
64122
}
65123

66124
[Fact]
67125
public void Configure_Defaults_RetainsLoopbackTrustAndOneHopLimit()
68126
{
127+
// Arrange
69128
var options = new ForwardedHeadersOptions();
129+
130+
// Act
70131
ForwardedHeadersConfiguration.Configure(options, new ConfigurationBuilder().Build());
132+
133+
// Assert
71134
Assert.NotEmpty(options.KnownProxies);
72135
Assert.NotEmpty(options.KnownIPNetworks);
73136
Assert.Equal(1, options.ForwardLimit);

0 commit comments

Comments
 (0)