DomainBlock (domain, severity, reject-media, public and private comment)
covers the domain and its subdomains. Admins manage them under
/clientapi/admin/domainblocks/{list,insert,delete}; the set is kept in
memory, reloaded on every change and at most five minutes stale.
A suspended domain is refused by FederationHttp.IsAllowed, so nothing is
fetched from it and no job delivers to it, and the inbox drops its
activities with a 202 before fetching any key. Reject-media strips the
attachments of posts from that domain. Silence is recorded for the
timelines and notifications that arrive in P1.2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
128 lines
5.6 KiB
C#
128 lines
5.6 KiB
C#
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.Federation.Moderation;
|
|
using PrivaPub.Infrastructure.Http;
|
|
using PrivaPub.Models.Federation;
|
|
|
|
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), new StaticBlocks(), 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)));
|
|
|
|
[Fact]
|
|
public void IsAllowed_refuses_a_suspended_domain_and_its_subdomains()
|
|
{
|
|
var http = new FederationHttp(new ServiceCollection().AddHttpClient().BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
|
|
new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"),
|
|
NullLogger<FederationHttp>.Instance);
|
|
|
|
Assert.False(http.IsAllowed(new Uri("https://evil.example/users/x")));
|
|
Assert.False(http.IsAllowed(new Uri("https://cdn.evil.example/a.png")));
|
|
Assert.True(http.IsAllowed(new Uri("https://notevil.example/users/x")));
|
|
}
|
|
|
|
sealed class StaticBlocks : IDomainBlocks
|
|
{
|
|
readonly string[] _suspended;
|
|
public StaticBlocks(params string[] suspended) => _suspended = suspended;
|
|
public DomainBlock Find(string host) => _suspended.Any(s => host == s || host.EndsWith("." + s))
|
|
? new DomainBlock { Domain = host, Severity = DomainBlockSeverity.Suspend }
|
|
: default;
|
|
public bool IsSuspended(string host) => Find(host) != default;
|
|
public Task Reload(CancellationToken token) => Task.CompletedTask;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|