fix(security): trusted-proxy ForwardedHeaders so LoginThrottle keys on the real client IP (plan R2-07 T2)

This commit is contained in:
Joseph Doherty
2026-07-13 10:38:41 -04:00
parent e1a692016e
commit b532a1e30e
3 changed files with 112 additions and 0 deletions
+12
View File
@@ -337,6 +337,18 @@ try
} }
// Middleware pipeline // Middleware pipeline
// Trusted-proxy forwarded headers (arch-review R2 N2): behind Traefik every client
// shares the proxy's IP, collapsing the LoginThrottle's {username}|{ip} keys onto
// one address (per-IP isolation gone + cheap username-lockout DoS). When enabled,
// X-Forwarded-For from the CONFIGURED proxies only is honored. MUST run first —
// everything downstream keys on Connection.RemoteIpAddress.
var forwardedOptions = builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions.SectionName)
.Get<ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSecurityOptions>() ?? new();
if (ZB.MOM.WW.ScadaBridge.Security.ForwardedHeadersSetup.Build(forwardedOptions) is { } fho)
app.UseForwardedHeaders(fho);
app.UseWebSockets(); app.UseWebSockets();
app.UseRouting(); app.UseRouting();
app.UseAuthentication(); app.UseAuthentication();
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using System.Net;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Config shape for trusted-proxy forwarded-header handling
/// (<c>ScadaBridge:Security:ForwardedHeaders</c>). Disabled by default: a deployment
/// with no reverse proxy must NOT honor X-Forwarded-For (a client could spoof its
/// throttle key). The documented Traefik topologies enable it with the docker
/// network range so LoginThrottle keys on the REAL client IP (arch-review R2 N2).
/// </summary>
public sealed class ForwardedHeadersSecurityOptions
{
/// <summary>Configuration section binding this options class.</summary>
public const string SectionName = "ScadaBridge:Security:ForwardedHeaders";
/// <summary>Whether X-Forwarded-For from trusted proxies is honored.</summary>
public bool Enabled { get; set; }
/// <summary>Exact proxy IPs allowed to supply X-Forwarded-For.</summary>
public string[] KnownProxies { get; set; } = [];
/// <summary>CIDR networks allowed to supply X-Forwarded-For (e.g. "172.16.0.0/12").</summary>
public string[] KnownNetworks { get; set; } = [];
}
/// <summary>
/// Builds the <see cref="ForwardedHeadersOptions"/> for <c>app.UseForwardedHeaders</c>.
/// Pure and fail-fast: malformed entries throw at startup rather than silently
/// trusting nothing/everything. Defaults (loopback) are CLEARED — only the
/// explicitly configured proxies/networks are trusted, and ForwardLimit=1 means
/// only the entry the trusted proxy itself appended is honored.
/// </summary>
public static class ForwardedHeadersSetup
{
/// <summary>
/// Builds the forwarded-headers options, or <see langword="null"/> when disabled.
/// </summary>
/// <param name="options">The bound <see cref="ForwardedHeadersSecurityOptions"/>.</param>
/// <returns>The configured <see cref="ForwardedHeadersOptions"/>, or <see langword="null"/> when disabled.</returns>
public static ForwardedHeadersOptions? Build(ForwardedHeadersSecurityOptions options)
{
if (!options.Enabled) return null;
var built = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
ForwardLimit = 1,
};
built.KnownProxies.Clear();
// KnownIPNetworks (System.Net.IPNetwork) is the net10 successor to the now-deprecated
// KnownNetworks; UseForwardedHeaders reads it identically.
built.KnownIPNetworks.Clear();
foreach (var proxy in options.KnownProxies)
built.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (var network in options.KnownNetworks)
{
var parts = network.Split('/');
built.KnownIPNetworks.Add(new(IPAddress.Parse(parts[0]), int.Parse(parts[1])));
}
return built;
}
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.HttpOverrides;
using ZB.MOM.WW.ScadaBridge.Security;
using Xunit;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
public class ForwardedHeadersSetupTests
{
[Fact]
public void Build_Disabled_ReturnsNull()
=> Assert.Null(ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions { Enabled = false }));
[Fact]
public void Build_Enabled_ParsesProxiesAndNetworks_AndClearsDefaults()
{
var built = ForwardedHeadersSetup.Build(new ForwardedHeadersSecurityOptions
{
Enabled = true,
KnownProxies = ["10.5.0.9"],
KnownNetworks = ["172.16.0.0/12"],
})!;
Assert.Equal(ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, built.ForwardedHeaders);
Assert.Equal(1, built.ForwardLimit);
Assert.Single(built.KnownProxies); // ONLY the configured proxy —
Assert.DoesNotContain(System.Net.IPAddress.IPv6Loopback, // the loopback default is cleared
built.KnownProxies);
Assert.Single(built.KnownIPNetworks); // net10 successor to the deprecated KnownNetworks
}
[Theory]
[InlineData("not-an-ip", "172.16.0.0/12")]
[InlineData("10.5.0.9", "not-a-cidr")]
public void Build_MalformedEntry_ThrowsAtStartup(string proxy, string network)
=> Assert.ThrowsAny<Exception>(() => ForwardedHeadersSetup.Build(
new ForwardedHeadersSecurityOptions { Enabled = true, KnownProxies = [proxy], KnownNetworks = [network] }));
}