w4c-workflows-api/Services/Security/EgressPolicy.cs
2026-09-12 01:02:46 +03:00

157 lines
6.3 KiB
C#

using System.Net;
using System.Net.Sockets;
namespace w4c_workflows.Services.Security;
/// <summary>
/// Server-side side of SSRF defence: decides whether an outbound target is
/// acceptable. The policy is deliberately split in two stages — a host/scheme
/// check that needs no I/O, and an address check applied to every IP the host
/// resolves to (or a literal IP). Evaluating all resolved addresses is what
/// closes the DNS-rebinding hole where a name points at a public and a private
/// address at once.
/// </summary>
public sealed class EgressPolicy
{
private readonly EgressPolicyOptions _options;
public EgressPolicy(EgressPolicyOptions options) => _options = options;
/// <summary>Configured ceiling on redirects a node invocation may follow.</summary>
public int MaxRedirects => Math.Max(0, _options.MaxRedirects);
/// <summary>
/// Stage one: validate the scheme and the host allow/deny lists. Returns a
/// final decision, or <c>null</c> when the caller must resolve the host and
/// call <see cref="CheckAddresses"/>.
/// </summary>
public EgressDecision? CheckHost(Uri uri)
{
if (!uri.IsAbsoluteUri)
return EgressDecision.Block("invalid_url", "the target must be an absolute URL");
if (!_options.AllowedSchemes.Any(s => string.Equals(s, uri.Scheme, StringComparison.OrdinalIgnoreCase)))
return EgressDecision.Block("scheme_not_allowed", $"scheme '{uri.Scheme}' is not allowed");
var host = HostOf(uri);
if (MatchesAny(host, _options.BlockedHosts))
return EgressDecision.Block("host_blocked", $"host '{host}' is blocked by policy");
// An explicit allow-list entry is an operator's opt-in for that host and
// deliberately short-circuits the reserved-range checks below.
if (MatchesAny(host, _options.AllowedHosts))
return EgressDecision.Permit();
if (_options.AllowPrivateNetworks)
return EgressDecision.Permit();
return null;
}
/// <summary>
/// Stage two: reject when any resolved address falls in a blocked range.
/// An empty address set means resolution failed and fails closed.
/// </summary>
public EgressDecision CheckAddresses(string host, IReadOnlyList<IPAddress> addresses)
{
if (addresses.Count == 0)
return EgressDecision.Block("dns_failure", $"host '{host}' did not resolve to an address");
foreach (var address in addresses)
{
if (IsBlockedAddress(address))
return EgressDecision.Block(
"blocked_address",
$"host '{host}' resolves to a blocked address ({address})");
}
return EgressDecision.Permit();
}
/// <summary>Convenience: stage one then stage two for a known address set.</summary>
public EgressDecision Evaluate(Uri uri, IReadOnlyList<IPAddress> addresses)
=> CheckHost(uri) ?? CheckAddresses(HostOf(uri), addresses);
/// <summary>
/// True when the address belongs to loopback, private, link-local,
/// carrier-grade-NAT or another reserved range that must not be reachable
/// from a workflow node. Unknown address families fail closed.
/// </summary>
public static bool IsBlockedAddress(IPAddress address)
{
// Normalise IPv4-mapped IPv6 (::ffff:127.0.0.1) to its IPv4 form so the
// range checks below cannot be sidestepped with a mapped literal.
if (address.IsIPv4MappedToIPv6)
address = address.MapToIPv4();
if (IPAddress.IsLoopback(address))
return true;
switch (address.AddressFamily)
{
case AddressFamily.InterNetwork:
return IsBlockedV4(address.GetAddressBytes());
case AddressFamily.InterNetworkV6:
if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal
|| address.IsIPv6Multicast || address.IsIPv6UniqueLocal)
return true;
var v6 = address.GetAddressBytes();
if (v6.All(b => b == 0))
return true; // unspecified ::
if (v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x0d && v6[3] == 0xb8)
return true; // 2001:db8::/32 documentation range
return false;
default:
return true;
}
}
/// <summary>Matches a host against a list of exact or <c>*.suffix</c> patterns.</summary>
public static bool MatchesAny(string host, IEnumerable<string> patterns)
{
foreach (var pattern in patterns)
{
if (!string.IsNullOrWhiteSpace(pattern) && Matches(host, pattern.Trim()))
return true;
}
return false;
}
private static bool IsBlockedV4(byte[] b)
{
if (b[0] == 0) return true; // 0.0.0.0/8 "this network"
if (b[0] == 10) return true; // 10.0.0.0/8
if (b[0] == 127) return true; // loopback
if (b[0] == 169 && b[1] == 254) return true; // link-local / cloud metadata
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12
if (b[0] == 192 && b[1] == 168) return true; // 192.168.0.0/16
if (b[0] == 100 && b[1] >= 64 && b[1] <= 127) return true; // CGNAT 100.64.0.0/10
if (b[0] == 192 && b[1] == 0 && b[2] == 0) return true; // 192.0.0.0/24 IETF protocol assignments
if (b[0] == 198 && (b[1] == 18 || b[1] == 19)) return true; // 198.18.0.0/15 benchmarking
if (b[0] == 255 && b[1] == 255) return true; // limited broadcast
return false;
}
private static bool Matches(string host, string pattern)
{
if (pattern == "*")
return true;
if (pattern.StartsWith("*.", StringComparison.Ordinal)
|| pattern.StartsWith('.'))
{
var suffix = pattern.StartsWith("*.", StringComparison.Ordinal) ? pattern[1..] : pattern;
return host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(host, pattern, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Host without IPv6 brackets, so literals and names compare uniformly.</summary>
private static string HostOf(Uri uri) => uri.DnsSafeHost;
}