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:
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user