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,102 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public sealed class FederationHttpTests : IAsyncLifetime
{
WebApplication _peer;
string _base;
public async ValueTask InitializeAsync()
{
var builder = WebApplication.CreateSlimBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
_peer = builder.Build();
_peer.MapGet("/actor", () => Results.Text("{\"id\":\"x\"}", "application/activity+json"));
_peer.MapGet("/html", () => Results.Text("<html></html>", "text/html"));
_peer.MapGet("/big", () => Results.Text("{\"a\":\"" + new string('a', FederationHttp.MaxResponseBytes) + "\"}", "application/activity+json"));
_peer.MapGet("/hop/{n:int}", (int n) => Results.Redirect(n == 0 ? "/actor" : $"/hop/{n - 1}"));
_peer.MapGet("/gone", () => Results.StatusCode(410));
await _peer.StartAsync();
_base = _peer.Urls.First();
}
public async ValueTask DisposeAsync() => await _peer.DisposeAsync();
static FederationHttp Client(bool allowTestNetwork = true)
{
var options = new FederationOptions { AllowPrivateNetworks = allowTestNetwork, AllowPlainHttp = allowTestNetwork };
var services = new ServiceCollection();
services.AddHttpClient(FederationHttp.ClientName)
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance);
}
[Fact]
public async Task Reads_a_json_document()
{
using var fetched = await Client().GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal("x", fetched.Root.GetProperty("id").GetString());
}
[Fact]
public async Task Follows_up_to_three_redirects_and_reports_the_final_url()
{
using var fetched = await Client().GetJson($"{_base}/hop/2", "application/activity+json", default, TestContext.Current.CancellationToken);
Assert.NotNull(fetched);
Assert.Equal($"{_base}/actor", fetched.FinalUri.ToString());
}
[Fact]
public async Task Refuses_a_fourth_redirect() =>
Assert.Null(await Client().GetJson($"{_base}/hop/3", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_html() =>
Assert.Null(await Client().GetJson($"{_base}/html", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_body_over_the_limit() =>
Assert.Null(await Client().GetJson($"{_base}/big", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_an_error_status() =>
Assert.Null(await Client().GetJson($"{_base}/gone", "application/activity+json", default, TestContext.Current.CancellationToken));
[Fact]
public async Task Refuses_a_private_network_in_production_mode() =>
Assert.Null(await Client(allowTestNetwork: false).GetJson($"{_base}/actor", "application/activity+json", default, TestContext.Current.CancellationToken));
[Theory]
[InlineData("https://mastodon.social/users/Gargron", true)]
[InlineData("http://mastodon.social/users/Gargron", false)]
[InlineData("https://user:pass@mastodon.social/", false)]
[InlineData("https://localhost/", false)]
[InlineData("https://printer.local/", false)]
[InlineData("https://metadata.google.internal/", false)]
[InlineData("https://127.0.0.1/", false)]
[InlineData("https://[::1]/", false)]
[InlineData("ftp://example.org/", false)]
public void IsAllowed_takes_https_dns_names_only(string url, bool allowed) =>
Assert.Equal(allowed, Client(allowTestNetwork: false).IsAllowed(new Uri(url)));
sealed class StaticOptionsMonitor : IOptionsMonitor<FederationOptions>
{
public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value;
public FederationOptions CurrentValue { get; }
public FederationOptions Get(string name) => CurrentValue;
public IDisposable OnChange(Action<FederationOptions, string> listener) => default;
}
}
}
@@ -0,0 +1,86 @@
using System.Net;
using PrivaPub.Infrastructure.Http;
namespace PrivaPub.Tests.Infrastructure
{
public class IpRangeGuardTests
{
[Theory]
[InlineData("1.1.1.1")]
[InlineData("8.8.8.8")]
[InlineData("93.184.215.14")]
[InlineData("172.15.255.255")]
[InlineData("172.32.0.1")]
[InlineData("100.63.255.255")]
[InlineData("100.128.0.0")]
[InlineData("2a01:4f8::1")]
[InlineData("2606:4700:4700::1111")]
[InlineData("::ffff:1.1.1.1")]
public void Public_addresses_are_allowed(string address) =>
Assert.True(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Theory]
[InlineData("0.0.0.0")]
[InlineData("10.1.2.3")]
[InlineData("100.64.0.1")]
[InlineData("100.127.255.255")]
[InlineData("127.0.0.1")]
[InlineData("127.255.255.254")]
[InlineData("169.254.169.254")]
[InlineData("172.16.0.1")]
[InlineData("172.31.255.255")]
[InlineData("192.0.0.170")]
[InlineData("192.0.2.1")]
[InlineData("192.88.99.1")]
[InlineData("192.168.1.1")]
[InlineData("198.18.0.1")]
[InlineData("198.51.100.7")]
[InlineData("203.0.113.9")]
[InlineData("224.0.0.251")]
[InlineData("239.255.255.250")]
[InlineData("240.0.0.1")]
[InlineData("255.255.255.255")]
[InlineData("::")]
[InlineData("::1")]
[InlineData("::127.0.0.1")]
[InlineData("::ffff:127.0.0.1")]
[InlineData("::ffff:10.0.0.1")]
[InlineData("::ffff:169.254.169.254")]
[InlineData("64:ff9b::7f00:1")]
[InlineData("64:ff9b::808:808")]
[InlineData("64:ff9b:1::1")]
[InlineData("100::1")]
[InlineData("2001::1")]
[InlineData("2001:0:4136:e378:8000:63bf:3fff:fdd2")]
[InlineData("2001:db8::1")]
[InlineData("2001:10::1")]
[InlineData("2002:7f00:1::1")]
[InlineData("2002:c0a8:101::1")]
[InlineData("3fff::1")]
[InlineData("fc00::1")]
[InlineData("fd12:3456:789a::1")]
[InlineData("fe80::1")]
[InlineData("fec0::1")]
[InlineData("ff02::1")]
public void Private_and_special_addresses_are_refused(string address) =>
Assert.False(IpRangeGuard.IsPublic(IPAddress.Parse(address)));
[Fact]
public async Task Connect_refuses_a_name_that_resolves_to_loopback()
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint("localhost", 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData("127.0.0.1")]
[InlineData("[::1]")]
[InlineData("169.254.169.254")]
public async Task Connect_refuses_a_private_literal(string host)
{
await Assert.ThrowsAsync<BlockedDestinationException>(async () =>
await SafeHttpHandlerFactory.Connect(new DnsEndPoint(host, 443), allowPrivateNetworks: false, TestContext.Current.CancellationToken));
}
}
}