w4c-workflows-api/w4c-workflows-api.Tests/EgressPolicyTests.cs
Vitali sharp8n 42ffcb9adc workflows
2026-09-13 19:28:47 +03:00

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);
}
}