181 lines
6.3 KiB
C#
181 lines
6.3 KiB
C#
using System.Net;
|
|
using w4c_workflows.Services.Security;
|
|
using Xunit;
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
/// <summary>
|
|
/// Pure policy tests: no DNS, no sockets. Every decision is driven by a
|
|
/// fabricated address set so the reserved-range table is exercised exhaustively.
|
|
/// </summary>
|
|
public class EgressPolicyTests
|
|
{
|
|
private static PolicyDecision Evaluate(
|
|
string url, params string[] addresses)
|
|
=> EgressTestData.Policy().Evaluate(new Uri(url), EgressTestData.Ips(addresses));
|
|
|
|
[Theory]
|
|
[InlineData("127.0.0.1")] // IPv4 loopback
|
|
[InlineData("127.10.20.30")] // whole 127/8
|
|
[InlineData("10.0.0.5")] // private
|
|
[InlineData("172.16.0.1")] // private lower bound
|
|
[InlineData("172.31.255.255")] // private upper bound
|
|
[InlineData("192.168.1.1")] // private
|
|
[InlineData("169.254.169.254")] // link-local / cloud metadata
|
|
[InlineData("100.64.0.1")] // CGNAT
|
|
[InlineData("100.127.255.255")] // CGNAT upper bound
|
|
[InlineData("0.0.0.0")] // unspecified
|
|
[InlineData("198.18.0.1")] // benchmarking
|
|
[InlineData("255.255.255.255")] // broadcast
|
|
[InlineData("::1")] // IPv6 loopback
|
|
[InlineData("fc00::1")] // IPv6 unique-local
|
|
[InlineData("fd12:3456::1")] // IPv6 unique-local
|
|
[InlineData("fe80::1")] // IPv6 link-local
|
|
[InlineData("::ffff:127.0.0.1")] // IPv4-mapped loopback
|
|
[InlineData("::ffff:10.0.0.1")] // IPv4-mapped private
|
|
public void Blocks_reserved_addresses(string address)
|
|
{
|
|
var decision = Evaluate("https://example.com/x", address);
|
|
|
|
Assert.False(decision.Allowed);
|
|
Assert.Equal("blocked_address", decision.Code);
|
|
Assert.Contains(address, decision.Reason);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("93.184.216.34")]
|
|
[InlineData("8.8.8.8")]
|
|
[InlineData("172.32.0.1")] // just outside 172.16/12
|
|
[InlineData("100.128.0.1")] // just outside 100.64/10
|
|
[InlineData("2001:4860:4860::8888")]
|
|
public void Allows_routable_addresses(string address)
|
|
{
|
|
Assert.True(Evaluate("https://example.com/x", address).Allowed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Blocks_when_any_resolved_address_is_reserved()
|
|
{
|
|
var decision = Evaluate("https://example.com/x", "93.184.216.34", "10.0.0.1");
|
|
|
|
Assert.False(decision.Allowed);
|
|
Assert.Equal("blocked_address", decision.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void Empty_address_set_fails_closed()
|
|
{
|
|
var decision = EgressTestData.Policy().CheckAddresses("gone.example.com", Array.Empty<IPAddress>());
|
|
|
|
Assert.False(decision.Allowed);
|
|
Assert.Equal("dns_failure", decision.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void Allow_private_networks_option_permits_reserved_ranges()
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.AllowPrivateNetworks = true);
|
|
|
|
Assert.True(policy.Evaluate(new Uri("http://10.0.0.5/x"), EgressTestData.Ips("10.0.0.5")).Allowed);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ftp")]
|
|
[InlineData("file")]
|
|
[InlineData("gopher")]
|
|
public void Blocks_non_http_schemes(string scheme)
|
|
{
|
|
var url = scheme == "file" ? "file:///etc/passwd" : $"{scheme}://example.com/x";
|
|
|
|
var decision = EgressTestData.Policy().CheckHost(new Uri(url));
|
|
|
|
Assert.NotNull(decision);
|
|
Assert.False(decision!.Allowed);
|
|
Assert.Equal("scheme_not_allowed", decision.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scheme_list_is_configurable()
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.AllowedSchemes = ["https"]);
|
|
|
|
var decision = policy.CheckHost(new Uri("http://example.com/x"));
|
|
|
|
Assert.NotNull(decision);
|
|
Assert.Equal("scheme_not_allowed", decision!.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void Blocks_explicitly_denied_host()
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.BlockedHosts = ["evil.example.com"]);
|
|
|
|
var decision = policy.CheckHost(new Uri("https://evil.example.com/x"));
|
|
|
|
Assert.NotNull(decision);
|
|
Assert.False(decision!.Allowed);
|
|
Assert.Equal("host_blocked", decision.Code);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deny_list_wins_over_allow_list()
|
|
{
|
|
var policy = EgressTestData.Policy(o =>
|
|
{
|
|
o.AllowedHosts = ["*.example.com"];
|
|
o.BlockedHosts = ["admin.example.com"];
|
|
});
|
|
|
|
var decision = policy.CheckHost(new Uri("https://admin.example.com/x"));
|
|
|
|
Assert.NotNull(decision);
|
|
Assert.False(decision!.Allowed);
|
|
Assert.Equal("host_blocked", decision.Code);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("https://internal.example.com/x", "internal.example.com")]
|
|
[InlineData("https://api.example.com/x", "*.example.com")]
|
|
[InlineData("https://api.example.com/x", ".example.com")]
|
|
[InlineData("https://anything.example.com/x", "*")]
|
|
[InlineData("https://EXAMPLE.com/x", "example.com")] // case-insensitive
|
|
public void Allowed_host_bypasses_address_checks(string url, string pattern)
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.AllowedHosts = [pattern]);
|
|
|
|
// A private address would normally be rejected; the explicit allow-list
|
|
// entry is the operator's opt-in and short-circuits that check.
|
|
var decision = policy.Evaluate(new Uri(url), EgressTestData.Ips("10.0.0.5"));
|
|
|
|
Assert.True(decision.Allowed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Wildcard_does_not_match_the_bare_suffix_host()
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.AllowedHosts = ["*.example.com"]);
|
|
|
|
// "*.example.com" matches subdomains only, mirroring cert wildcard rules.
|
|
Assert.Null(policy.CheckHost(new Uri("https://example.com/x")));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("sub.example.com", "*.example.com", true)]
|
|
[InlineData("example.com", "*.example.com", false)]
|
|
[InlineData("example.com", "example.com", true)]
|
|
[InlineData("example.com.evil.com", "example.com", false)]
|
|
public void Host_matcher_handles_wildcards_and_exact(
|
|
string host, string pattern, bool expected)
|
|
{
|
|
Assert.Equal(expected, EgressPolicy.MatchesAny(host, new[] { pattern }));
|
|
}
|
|
|
|
[Fact]
|
|
public void Max_redirects_never_goes_negative()
|
|
{
|
|
var policy = EgressTestData.Policy(o => o.MaxRedirects = -3);
|
|
|
|
Assert.Equal(0, policy.MaxRedirects);
|
|
}
|
|
}
|