Every outbound federation request goes through one guarded client

Infrastructure/Http adds the client the roadmap's S3 and S4 ask for:
- the connect callback resolves the name itself and refuses loopback,
  private, link-local, CGNAT, documentation, multicast, ULA, NAT64, 6to4,
  Teredo and IPv4-mapped/compatible forms, then connects to the vetted
  address, so DNS rebinding cannot swap it afterwards;
- redirects are followed by hand, at most three, each one re-checked;
- bodies are capped at 1 MB after decompression, only JSON media types are
  read, every request has a 15 s budget, and a refused URL is not asked
  again for five minutes.

Actor and WebFinger fetches and inbox deliveries all use it. Test networks
can switch on Federation:AllowPrivateNetworks/AllowPlainHttp; startup
refuses both in Production.

PrivaPub.Tests (xUnit v3) starts with the address table and the fetcher's
limits against an in-process peer; build.yml and deploy.yml run it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 10:46:09 +02:00
1 parent 034b792801
commit ccc3597699
14 files changed
+579 -54

No files matched your search

@@ -0,0 +1,58 @@
using System.Net;
using System.Net.Sockets;
namespace PrivaPub.Infrastructure.Http
{
public sealed class BlockedDestinationException : Exception
{
public BlockedDestinationException(string host) : base($"'{host}' does not resolve to a public address") { }
}
public static class SafeHttpHandlerFactory
{
public static SocketsHttpHandler Create(FederationOptions options) => new()
{
AllowAutoRedirect = false,
UseProxy = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.All,
ConnectTimeout = TimeSpan.FromSeconds(10),
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxResponseHeadersLength = 64,
ConnectCallback = (context, token) => Connect(context.DnsEndPoint, options.AllowPrivateNetworks, token)
};
public static async ValueTask<Stream> Connect(DnsEndPoint endPoint, bool allowPrivateNetworks, CancellationToken token)
{
var addresses = await Resolve(endPoint.Host, token);
if (addresses.Length == 0 || !allowPrivateNetworks && !addresses.All(IpRangeGuard.IsPublic))
throw new BlockedDestinationException(endPoint.Host);
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(addresses, endPoint.Port, token);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
static async Task<IPAddress[]> Resolve(string host, CancellationToken token)
{
if (IPAddress.TryParse(host.Trim('[', ']'), out var literal))
return new[] { literal };
try
{
return await Dns.GetHostAddressesAsync(host, token);
}
catch (SocketException)
{
return Array.Empty<IPAddress>();
}
}
}
}