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

@@ -9,6 +9,7 @@ using System.Text;
using System.Text.Json.Nodes;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Federation.Outbox
{
@@ -69,13 +70,13 @@ namespace PrivaPub.Federation.Outbox
static readonly TimeSpan Poll = TimeSpan.FromSeconds(3);
readonly IServiceProvider _services;
readonly IHttpClientFactory _httpClientFactory;
readonly IFederationHttp _http;
readonly ILogger<DeliveryWorker> _logger;
public DeliveryWorker(IServiceProvider services, IHttpClientFactory httpClientFactory, ILogger<DeliveryWorker> logger)
public DeliveryWorker(IServiceProvider services, IFederationHttp http, ILogger<DeliveryWorker> logger)
{
_services = services;
_httpClientFactory = httpClientFactory;
_http = http;
_logger = logger;
}
@@ -131,7 +132,7 @@ namespace PrivaPub.Federation.Outbox
delivery.Attempts++;
try
{
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !RemoteActorService.IsFetchable(inbox))
if (!Uri.TryCreate(delivery.InboxURL, UriKind.Absolute, out var inbox) || !_http.IsAllowed(inbox))
{
delivery.AbandonedAt = DateTime.UtcNow;
delivery.LastError = "not a deliverable inbox";
@@ -146,7 +147,7 @@ namespace PrivaPub.Federation.Outbox
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(RemoteActorService.ActivityJson);
HttpSignatures.Sign(request, signer, body);
using var response = await _httpClientFactory.CreateClient(RemoteActorService.HttpClientName).SendAsync(request, token);
using var response = await _http.Send(request, token);
if (response.IsSuccessStatusCode)
{
delivery.DeliveredAt = DateTime.UtcNow;
@@ -162,7 +163,7 @@ namespace PrivaPub.Federation.Outbox
return;
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !token.IsCancellationRequested)
catch (Exception ex) when (ex is HttpRequestException or BlockedDestinationException or TaskCanceledException && !token.IsCancellationRequested)
{
delivery.LastError = ex.Message;
}