157 lines
6.3 KiB
C#
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;
|
|
}
|