Domain blocks: suspend, silence, reject media

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
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-01 11:25:05 +02:00
1 parent 9ec1f9930b
commit e6a362c0b8
13 files changed
+318 -13

No files matched your search

@@ -4,16 +4,18 @@ using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Inbox.Handlers;
using PrivaPub.Models.Jobs;
using PrivaPub.Federation.Inbox;
using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Outbox;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.Models;
using PrivaPub.StaticServices;
using PrivaPub.Tests.Support;
@@ -33,6 +35,7 @@ namespace PrivaPub.Tests.Federation
LocalActorService _local;
InboxReceiver _receiver;
InboxProcessor _processor;
DomainBlocks _blocks;
public async ValueTask InitializeAsync()
{
@@ -44,12 +47,13 @@ namespace PrivaPub.Tests.Federation
var queue = new JobQueue();
var delivery = new DeliveryService(new DbEntities(), queue);
var db = new DbEntities();
_receiver = new InboxReceiver(_local, remote, queue, NullLogger<InboxReceiver>.Instance);
_blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
_receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger<InboxReceiver>.Instance);
_processor = new InboxProcessor(remote, new IActivityHandler[]
{
new FollowHandler(db, _local, remote, delivery),
new UndoHandler(db, _local, remote, delivery),
new CreateHandler(db, _local, remote, delivery),
new CreateHandler(db, _local, remote, delivery, _blocks),
new DeleteHandler(db, _local, remote, delivery),
new UpdateHandler(db, _local, remote)
}, NullLogger<InboxProcessor>.Instance);
@@ -254,6 +258,46 @@ namespace PrivaPub.Tests.Federation
Assert.Equal(alice.Id, mention.AccountId);
}
[Fact]
public async Task A_suspended_domain_is_dropped_before_its_key_is_fetched()
{
var token = TestContext.Current.CancellationToken;
var alice = await LocalAvatar("alice");
var bob = new RemoteActor(_peer, "bob", _peer.B);
await DB.Default.SaveAsync(new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend }, token);
try
{
await _blocks.Reload(token);
var before = _peer.Requests.Count;
var result = await Deliver(bob, $"/peasants/{alice.UserName}/mouth", DirectCreate(bob, alice.Uri));
Assert.Equal(202, result.StatusCode);
Assert.Equal(before, _peer.Requests.Count);
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token));
}
finally
{
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == "localhost");
await _blocks.Reload(token);
}
}
[Fact]
public void A_block_covers_subdomains_but_not_lookalikes()
{
var blocks = new DomainBlocks(NullLogger<DomainBlocks>.Instance);
typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, new Dictionary<string, DomainBlock> { ["evil.example"] = new() { Domain = "evil.example", Severity = DomainBlockSeverity.Silence } });
typeof(DomainBlocks).GetField("_loadedAt", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(blocks, DateTime.UtcNow);
Assert.NotNull(blocks.Find("evil.example"));
Assert.NotNull(blocks.Find("A.Evil.Example."));
Assert.Null(blocks.Find("notevil.example"));
Assert.False(blocks.IsSuspended("evil.example"));
}
[Fact]
public async Task A_bad_signature_is_a_401()
{
@@ -6,7 +6,9 @@ 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
{
@@ -39,7 +41,7 @@ namespace PrivaPub.Tests.Infrastructure
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
var provider = services.BuildServiceProvider();
return new FederationHttp(provider.GetRequiredService<IHttpClientFactory>(), new MemoryCache(new MemoryCacheOptions()),
new StaticOptionsMonitor(options), NullLogger<FederationHttp>.Instance);
new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger<FederationHttp>.Instance);
}
[Fact]
@@ -91,6 +93,29 @@ namespace PrivaPub.Tests.Infrastructure
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;
+11 -2
View File
@@ -6,7 +6,9 @@ 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;
using System.Collections.Concurrent;
@@ -63,7 +65,7 @@ namespace PrivaPub.Tests.Support
public void Answer(string path, int status, TimeSpan delay = default) => _answers[path] = (status, delay);
public static FederationHttp Http(IMemoryCache cache = default)
public static FederationHttp Http(IMemoryCache cache = default, IDomainBlocks blocks = default)
{
var options = new FederationOptions { AllowPrivateNetworks = true, AllowPlainHttp = true };
var services = new ServiceCollection();
@@ -71,12 +73,19 @@ namespace PrivaPub.Tests.Support
.ConfigurePrimaryHttpMessageHandler(() => SafeHttpHandlerFactory.Create(options));
return new FederationHttp(services.BuildServiceProvider().GetRequiredService<IHttpClientFactory>(),
cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions<FederationOptions>(options),
NullLogger<FederationHttp>.Instance);
blocks ?? new NoBlocks(), NullLogger<FederationHttp>.Instance);
}
public async ValueTask DisposeAsync() => await _app.DisposeAsync();
}
public sealed class NoBlocks : IDomainBlocks
{
public DomainBlock Find(string host) => default;
public bool IsSuspended(string host) => false;
public Task Reload(CancellationToken token) => Task.CompletedTask;
}
public sealed record HttpRequestRecord(string Method, string Path, string Signature);
public sealed class StaticOptions<T> : IOptionsMonitor<T>