using System.Net; using System.Net.Sockets; using DodoSSH.Api.Setup; namespace DodoSSH.Api.Features.Relay; /// /// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written. /// /// /// /// This runs on addresses, never on names. The name is resolved once, at ticket issue, and what /// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the /// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback /// one for the dial — which is the attack ADR 0004 is built around. /// /// /// Three layers, and the order is the security property. The list comes first and /// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a /// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials, /// then their allow-list, then the private-network switch. /// /// /// An address that embeds another is judged as the one it embeds. ::ffff:127.0.0.1 is /// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass /// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because /// on a network with a NAT64 gateway 64:ff9b::a9fe:a9fe is the metadata endpoint. /// /// internal sealed class RelayAddressPolicy { /// /// Addresses the relay never dials, whatever it is configured to do. /// /// /// Broader than the ADR's list in two places, both deliberately. 240.0.0.0/4 — reserved, and /// containing the limited broadcast address — has no host worth reaching. And IPv6 ::/96, the /// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing /// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there. /// private static readonly IPNetwork[] Refused = [ // IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved. IPNetwork.Parse("0.0.0.0/8"), IPNetwork.Parse("127.0.0.0/8"), IPNetwork.Parse("169.254.0.0/16"), IPNetwork.Parse("224.0.0.0/4"), IPNetwork.Parse("240.0.0.0/4"), // Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would // otherwise be permitted as private — the range a Tailnet also uses, which is why the whole // block cannot simply be refused. IPNetwork.Parse("100.100.100.200/32"), // IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS // metadata endpoint's IPv6 address. IPNetwork.Parse("::/96"), IPNetwork.Parse("fe80::/10"), IPNetwork.Parse("ff00::/8"), IPNetwork.Parse("fd00:ec2::254/128"), ]; /// /// Ranges governs. /// /// /// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and /// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local /// fec0::/10 is deprecated and was private while it lived. /// private static readonly IPNetwork[] Private = [ IPNetwork.Parse("10.0.0.0/8"), IPNetwork.Parse("172.16.0.0/12"), IPNetwork.Parse("192.168.0.0/16"), IPNetwork.Parse("100.64.0.0/10"), IPNetwork.Parse("fc00::/7"), IPNetwork.Parse("fec0::/10"), ]; /// The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address. private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96"); /// /// Ports the relay never dials. /// /// /// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the /// operator's address on it. Database ports stay open, because forwarding to one is a headline use, /// and the control that matters there is who may dial the host at all. /// private static readonly int[] RefusedPorts = [25, 465, 587]; private readonly bool allowPrivate; private readonly IPNetwork[] allowed; private readonly IPNetwork[] denied; public RelayAddressPolicy(RelayOptions options) { ArgumentNullException.ThrowIfNull(options); allowPrivate = options.AllowPrivateNetworks; allowed = ParseAll(options.AllowedNetworks); denied = ParseAll(options.DeniedNetworks); } /// Whether a port may be dialled at all. internal static bool PermitsPort(int port) => port is >= 1 and <= 65535 && !RefusedPorts.Contains(port); /// /// Whether every entry is a network this policy can read. /// /// /// For startup validation. A typo in a deny list that was silently skipped would be a hole the /// operator believes is closed, so an unreadable entry stops the server instead. /// /// Host bits are refused as well, although /// would mask them off quietly. 10.20.5.7/16 could be a /16 written from memory or a /32 with /// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses. /// /// internal static bool AreNetworks(IEnumerable entries) => entries.All(entry => IPNetwork.TryParse(entry, out var network) && IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written) && written.Equals(network.BaseAddress)); /// /// The address the relay would actually be talking to, with any embedding unwrapped. /// internal static IPAddress Normalise(IPAddress address) { ArgumentNullException.ThrowIfNull(address); if (address.IsIPv4MappedToIPv6) { return address.MapToIPv4(); } if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address)) { var bytes = address.GetAddressBytes(); return new IPAddress(bytes.AsSpan(12, 4)); } // A scope id is how an address names an interface on this machine, which is not something a // host row gets to choose. Dropped, so what is judged is what is dialled. return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0 ? new IPAddress(address.GetAddressBytes()) : address; } /// Whether the relay may dial this address. /// /// Judges the normalised address. Callers should dial 's answer too, so that /// the address checked and the address connected to are the same object rather than two spellings /// the operating system is trusted to agree about. /// internal bool Permits(IPAddress address) { var target = Normalise(address); if (Refused.Any(network => Within(network, target))) { return false; } if (denied.Any(network => Within(network, target))) { return false; } if (allowed.Length > 0) { return allowed.Any(network => Within(network, target)); } return allowPrivate || !Private.Any(network => Within(network, target)); } /// /// answers false across address families rather than throwing, so /// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line /// alternative reads as though it compared across families. /// private static bool Within(IPNetwork network, IPAddress address) => network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address); private static IPNetwork[] ParseAll(IEnumerable entries) => [.. entries.Select(entry => IPNetwork.Parse(entry))]; }