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
@@ -3,9 +3,10 @@ using MongoDB.Entities;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.StaticServices;
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
using PrivaPub.Federation.Signing;
|
||||
using PrivaPub.Infrastructure.Http;
|
||||
|
||||
namespace PrivaPub.Federation.Actors
|
||||
{
|
||||
@@ -19,41 +20,24 @@ namespace PrivaPub.Federation.Actors
|
||||
|
||||
public class RemoteActorService : IRemoteActorService
|
||||
{
|
||||
public const string HttpClientName = "ActivityPub";
|
||||
public const string ActivityJson = "application/activity+json";
|
||||
const string Accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
|
||||
static readonly TimeSpan CacheLifetime = TimeSpan.FromDays(1);
|
||||
|
||||
readonly IHttpClientFactory _httpClientFactory;
|
||||
readonly IFederationHttp _http;
|
||||
readonly DbEntities _dbEntities;
|
||||
readonly ILogger<RemoteActorService> _logger;
|
||||
|
||||
public RemoteActorService(IHttpClientFactory httpClientFactory, DbEntities dbEntities, ILogger<RemoteActorService> logger)
|
||||
public RemoteActorService(IFederationHttp http, DbEntities dbEntities)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_http = http;
|
||||
_dbEntities = dbEntities;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<JsonDocument> Fetch(string uri, LocalActor signAs, CancellationToken token)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
return default;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.ParseAdd(Accept);
|
||||
if (signAs != default)
|
||||
HttpSignatures.Sign(request, signAs, body: null);
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation("GET {Uri} answered {Status}", uri, (int)response.StatusCode);
|
||||
return default;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
var fetched = await _http.GetJson(uri, Accept,
|
||||
signAs == default ? default : request => HttpSignatures.Sign(request, signAs, body: null), token);
|
||||
return fetched?.Document;
|
||||
}
|
||||
|
||||
public async Task<ForeignAvatar> GetActor(string actorUri, LocalActor signAs, bool refresh, CancellationToken token)
|
||||
@@ -108,17 +92,10 @@ namespace PrivaPub.Federation.Actors
|
||||
return default;
|
||||
|
||||
var url = $"https://{parts[1]}/.well-known/webfinger?resource={Uri.EscapeDataString($"acct:{parts[0]}@{parts[1]}")}";
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsFetchable(target))
|
||||
using var fetched = await _http.GetJson(url, "application/jrd+json, application/json", sign: default, token);
|
||||
if (fetched == default)
|
||||
return default;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, target);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/jrd+json"));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var response = await _httpClientFactory.CreateClient(HttpClientName).SendAsync(request, token);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return default;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(token);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: token);
|
||||
var document = fetched.Document;
|
||||
if (!document.RootElement.TryGetProperty("links", out var links) || links.ValueKind != JsonValueKind.Array)
|
||||
return default;
|
||||
|
||||
@@ -163,13 +140,6 @@ namespace PrivaPub.Federation.Actors
|
||||
return avatar;
|
||||
}
|
||||
|
||||
public static bool IsFetchable(Uri target) =>
|
||||
target.Scheme == Uri.UriSchemeHttps
|
||||
&& target.HostNameType == UriHostNameType.Dns
|
||||
&& !target.IsLoopback
|
||||
&& !target.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|
||||
&& target.Host.Contains('.');
|
||||
|
||||
public static string StripFragment(string uri)
|
||||
{
|
||||
var hash = uri.IndexOf('#');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user