using System.Net; using JobTrackerApi.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Xunit; namespace JobTrackerApi.Tests; public sealed class ForwardedIngressTests { private static async Task Forward(string peer, string forwardedFor, string proto = "https") { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Proxy:KnownNetworks:0"] = "172.31.250.0/29", }).Build(); var context = new DefaultHttpContext(); context.Connection.RemoteIpAddress = IPAddress.Parse(peer); context.Request.Scheme = "http"; context.Request.Host = new HostString("jobs.example.test"); context.Request.Headers["X-Forwarded-For"] = forwardedFor; context.Request.Headers["X-Forwarded-Proto"] = proto; context.Request.Headers["X-Forwarded-Host"] = "attacker.example.test"; context.Request.Headers["Forwarded"] = "for=203.0.113.99;proto=https;host=attacker.example.test"; var middleware = new ForwardedHeadersMiddleware(_ => Task.CompletedTask, NullLoggerFactory.Instance, Options.Create(ForwardedProxyConfiguration.Build(config))); await middleware.Invoke(context); Assert.Equal("jobs.example.test", context.Request.Host.Value); return context; } [Theory] [InlineData("172.31.250.2", "198.51.100.10")] [InlineData("172.31.250.2", "198.51.100.11")] [InlineData("::ffff:172.31.250.2", "2001:db8::1")] public async Task Trusted_nginx_peer_resolves_client_and_scheme_but_never_forwarded_host(string peer, string client) { var context = await Forward(peer, client); Assert.Equal(IPAddress.Parse(client), context.Connection.RemoteIpAddress); Assert.Equal("https", context.Request.Scheme); } [Theory] [InlineData("198.51.100.20")] [InlineData("172.31.251.2")] [InlineData("::ffff:198.51.100.20")] [InlineData("2001:db8::20")] public async Task Untrusted_socket_peer_cannot_forge_IP_scheme_or_host(string peer) { var context = await Forward(peer, "203.0.113.99"); Assert.Equal(IPAddress.Parse(peer), context.Connection.RemoteIpAddress); Assert.Equal("http", context.Request.Scheme); } [Theory] [InlineData("not-an-ip")] [InlineData("999.999.999.999")] [InlineData("198.51.100.1, invalid")] public async Task Invalid_rightmost_address_cannot_manufacture_a_client(string input) { var context = await Forward("172.31.250.2", input); Assert.Equal(IPAddress.Parse("172.31.250.2"), context.Connection.RemoteIpAddress); Assert.Equal("http", context.Request.Scheme); } [Fact] public async Task Backend_defence_consumes_only_one_hop_even_if_nginx_contract_is_broken() { var context = await Forward("172.31.250.2", "203.0.113.99, 198.51.100.10"); Assert.Equal(IPAddress.Parse("198.51.100.10"), context.Connection.RemoteIpAddress); Assert.Equal("203.0.113.99", context.Request.Headers["X-Forwarded-For"]); } [Theory] [InlineData("0.0.0.0/0")] [InlineData("::/0")] public void Trusting_the_entire_internet_fails_configuration(string value) { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Proxy:KnownNetworks:0"] = value, }).Build(); Assert.Throws(() => ForwardedProxyConfiguration.Build(config)); } }