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:
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user