diff --git a/PrivaPub.ClientModels/Admin/DomainBlockForm.cs b/PrivaPub.ClientModels/Admin/DomainBlockForm.cs new file mode 100644 index 0000000..d59c8fe --- /dev/null +++ b/PrivaPub.ClientModels/Admin/DomainBlockForm.cs @@ -0,0 +1,33 @@ +using PrivaPub.ClientModels.Resources; + +using System.ComponentModel.DataAnnotations; + +namespace PrivaPub.ClientModels.Admin +{ + public class DomainBlockForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(253, MinimumLength = 3, ErrorMessageResourceName = "StringLengthMinMax", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Domain { get; set; } + + public bool Suspend { get; set; } = true; + public bool RejectMedia { get; set; } + + [StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))] + public string PublicComment { get; set; } + + [StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))] + public string PrivateComment { get; set; } + } + + public class ViewDomainBlock + { + public string Id { get; set; } + public string Domain { get; set; } + public string Severity { get; set; } + public bool RejectMedia { get; set; } + public string PublicComment { get; set; } + public string PrivateComment { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/PrivaPub.Tests/Federation/InboxScenarioTests.cs b/PrivaPub.Tests/Federation/InboxScenarioTests.cs index d219ce3..82772c4 100644 --- a/PrivaPub.Tests/Federation/InboxScenarioTests.cs +++ b/PrivaPub.Tests/Federation/InboxScenarioTests.cs @@ -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.Instance); + _blocks = new DomainBlocks(NullLogger.Instance); + _receiver = new InboxReceiver(_local, remote, queue, _blocks, NullLogger.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.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().Match(p => p.ActorURI == bob.Id).ExecuteAnyAsync(token)); + } + finally + { + await DB.Default.DeleteAsync(b => b.Domain == "localhost"); + await _blocks.Reload(token); + } + } + + [Fact] + public void A_block_covers_subdomains_but_not_lookalikes() + { + var blocks = new DomainBlocks(NullLogger.Instance); + typeof(DomainBlocks).GetField("_blocks", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(blocks, new Dictionary { ["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() { diff --git a/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs b/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs index e641ace..b4958c9 100644 --- a/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs +++ b/PrivaPub.Tests/Infrastructure/FederationHttpTests.cs @@ -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(), new MemoryCache(new MemoryCacheOptions()), - new StaticOptionsMonitor(options), NullLogger.Instance); + new StaticOptionsMonitor(options), new StaticBlocks(), NullLogger.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(), + new MemoryCache(new MemoryCacheOptions()), new StaticOptionsMonitor(new FederationOptions()), new StaticBlocks("evil.example"), + NullLogger.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 { public StaticOptionsMonitor(FederationOptions value) => CurrentValue = value; diff --git a/PrivaPub.Tests/Support/Peer.cs b/PrivaPub.Tests/Support/Peer.cs index d3ac7bb..5172a77 100644 --- a/PrivaPub.Tests/Support/Peer.cs +++ b/PrivaPub.Tests/Support/Peer.cs @@ -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(), cache ?? new MemoryCache(new MemoryCacheOptions()), new StaticOptions(options), - NullLogger.Instance); + blocks ?? new NoBlocks(), NullLogger.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 : IOptionsMonitor diff --git a/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs new file mode 100644 index 0000000..0acbf42 --- /dev/null +++ b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; + +using MongoDB.Entities; + +using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Admin; +using PrivaPub.Federation.Moderation; +using PrivaPub.Models.Federation; +using PrivaPub.Resources; + +namespace PrivaPub.Controllers.ClientToServer +{ + [ApiController, + Route("clientapi/admin/domainblocks"), + Authorize(Policy = Policies.IsAdmin)] + public class DomainBlockController : ControllerBase + { + readonly IDomainBlocks _domainBlocks; + readonly IStringLocalizer _localizer; + + public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer localizer) + { + _domainBlocks = domainBlocks; + _localizer = localizer; + } + + [HttpGet, Route("/clientapi/admin/domainblocks/list")] + public async Task List(CancellationToken token) => + Ok((await DB.Default.Find().Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)).Select(ToView).ToList()); + + [HttpPost, Route("/clientapi/admin/domainblocks/insert")] + public async Task Insert(DomainBlockForm form, CancellationToken token) + { + var domain = DomainBlocks.Normalise(form.Domain); + if (!ModelState.IsValid || Uri.CheckHostName(domain) != UriHostNameType.Dns) + return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."])); + + var block = await DB.Default.UpdateAndGet() + .Match(b => b.Domain == domain) + .Modify(b => b.Domain, domain) + .Modify(b => b.Severity, form.Suspend ? DomainBlockSeverity.Suspend : DomainBlockSeverity.Silence) + .Modify(b => b.RejectMedia, form.RejectMedia) + .Modify(b => b.PublicComment, form.PublicComment) + .Modify(b => b.PrivateComment, form.PrivateComment) + .Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow)) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); + await _domainBlocks.Reload(token); + return Ok(ToView(block)); + } + + [HttpPost, Route("/clientapi/admin/domainblocks/delete")] + public async Task Delete([FromQuery] string domain, CancellationToken token) + { + domain = DomainBlocks.Normalise(domain); + await DB.Default.DeleteAsync(b => b.Domain == domain); + await _domainBlocks.Reload(token); + return Ok(); + } + + static ViewDomainBlock ToView(DomainBlock block) => new() + { + Id = block.ID, + Domain = block.Domain, + Severity = block.Severity.ToString(), + RejectMedia = block.RejectMedia, + PublicComment = block.PublicComment, + PrivateComment = block.PrivateComment, + CreatedAt = block.CreatedAt + }; + } +} diff --git a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs index fb56b90..9093f5b 100644 --- a/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/CreateHandler.cs @@ -2,6 +2,7 @@ using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Rendering; @@ -26,13 +27,16 @@ namespace PrivaPub.Federation.Inbox.Handlers readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IDeliveryService _delivery; + readonly IDomainBlocks _domainBlocks; - public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery) + public CreateHandler(DbEntities dbEntities, ILocalActorService localActors, IRemoteActorService remoteActors, IDeliveryService delivery, + IDomainBlocks domainBlocks) { _dbEntities = dbEntities; _localActors = localActors; _remoteActors = remoteActors; _delivery = delivery; + _domainBlocks = domainBlocks; } public string Type => "Create"; @@ -130,7 +134,7 @@ namespace PrivaPub.Federation.Inbox.Handlers Language = note.Language, Mentions = mentions, Tags = note.Tags.ToList(), - Media = note.Attachments.ToList(), + Media = _domainBlocks.Find(new Uri(author.ActorURI).Host)?.RejectMedia == true ? new() : note.Attachments.ToList(), InReplyToURI = note.InReplyTo, AnsweringToPostId = parent?.ID, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index 404fb43..3c2a49b 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -1,4 +1,5 @@ using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Jobs; @@ -28,13 +29,16 @@ namespace PrivaPub.Federation.Inbox readonly ILocalActorService _localActors; readonly IRemoteActorService _remoteActors; readonly IJobQueue _queue; + readonly IDomainBlocks _domainBlocks; readonly ILogger _logger; - public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, ILogger logger) + public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks, + ILogger logger) { _localActors = localActors; _remoteActors = remoteActors; _queue = queue; + _domainBlocks = domainBlocks; _logger = logger; } @@ -69,6 +73,8 @@ namespace PrivaPub.Federation.Inbox var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); if (parameters == default) return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header"); + if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri))) + return new(StatusCodes.Status202Accepted); var requestProblem = HttpSignatures.CheckRequest(request, parameters, body); if (requestProblem != default) @@ -99,6 +105,8 @@ namespace PrivaPub.Federation.Inbox return new(StatusCodes.Status202Accepted); } + static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default; + async Task ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token) { var activityId = Id(activity); diff --git a/PrivaPub/Federation/Moderation/DomainBlocks.cs b/PrivaPub/Federation/Moderation/DomainBlocks.cs new file mode 100644 index 0000000..3275aec --- /dev/null +++ b/PrivaPub/Federation/Moderation/DomainBlocks.cs @@ -0,0 +1,77 @@ +using MongoDB.Entities; + +using PrivaPub.Models.Federation; + +namespace PrivaPub.Federation.Moderation +{ + public interface IDomainBlocks + { + DomainBlock Find(string host); + bool IsSuspended(string host); + Task Reload(CancellationToken token); + } + + public class DomainBlocks : IDomainBlocks + { + static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5); + + readonly ILogger _logger; + IReadOnlyDictionary _blocks = new Dictionary(); + DateTime _loadedAt = DateTime.MinValue; + int _reloading; + + public DomainBlocks(ILogger logger) + { + _logger = logger; + } + + public static string Normalise(string domain) => domain?.Trim().Trim('.').ToLowerInvariant(); + + public DomainBlock Find(string host) + { + RefreshIfStale(); + host = Normalise(host); + var blocks = _blocks; + while (!string.IsNullOrEmpty(host)) + { + if (blocks.TryGetValue(host, out var block)) + return block; + var dot = host.IndexOf('.'); + host = dot < 0 ? default : host[(dot + 1)..]; + } + return default; + } + + public bool IsSuspended(string host) => Find(host)?.Severity == DomainBlockSeverity.Suspend; + + public async Task Reload(CancellationToken token) + { + var blocks = await DB.Default.Find().ExecuteAsync(token); + _blocks = blocks.Where(b => !string.IsNullOrEmpty(b.Domain)) + .GroupBy(b => Normalise(b.Domain)) + .ToDictionary(g => g.Key, g => g.First()); + _loadedAt = DateTime.UtcNow; + } + + void RefreshIfStale() + { + if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1) + return; + _ = Task.Run(async () => + { + try + { + await Reload(CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Domain blocks could not be reloaded"); + } + finally + { + Interlocked.Exchange(ref _reloading, 0); + } + }); + } + } +} diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 785be55..c79a0b5 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -59,6 +59,7 @@ namespace PrivaPub.Infrastructure.Data .Key(j => j.FinishedAt, KeyType.Ascending) .Option(o => o.ExpireAfter = TimeSpan.FromDays(7)) .CreateAsync(token); + await Unique(b => b.Domain, Builders.Filter.Type(b => b.Domain, BsonType.String), token); await Unique(i => i.Host, Builders.Filter.Type(i => i.Host, BsonType.String), token); } diff --git a/PrivaPub/Infrastructure/Http/FederationHttp.cs b/PrivaPub/Infrastructure/Http/FederationHttp.cs index 832a2a1..15e84fa 100644 --- a/PrivaPub/Infrastructure/Http/FederationHttp.cs +++ b/PrivaPub/Infrastructure/Http/FederationHttp.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; +using PrivaPub.Federation.Moderation; + using System.Net; using System.Text.Json; @@ -41,14 +43,16 @@ namespace PrivaPub.Infrastructure.Http readonly IHttpClientFactory _httpClientFactory; readonly IMemoryCache _cache; readonly IOptionsMonitor _options; + readonly IDomainBlocks _domainBlocks; readonly ILogger _logger; public FederationHttp(IHttpClientFactory httpClientFactory, IMemoryCache cache, IOptionsMonitor options, - ILogger logger) + IDomainBlocks domainBlocks, ILogger logger) { _httpClientFactory = httpClientFactory; _cache = cache; _options = options; + _domainBlocks = domainBlocks; _logger = logger; } @@ -56,6 +60,8 @@ namespace PrivaPub.Infrastructure.Http { if (target is not { IsAbsoluteUri: true } || !string.IsNullOrEmpty(target.UserInfo)) return false; + if (_domainBlocks?.IsSuspended(target.Host) == true) + return false; var options = _options.CurrentValue; if (target.Scheme != Uri.UriSchemeHttps && !(options.AllowPlainHttp && target.Scheme == Uri.UriSchemeHttp)) return false; diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index b7ae4d8..7a83904 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -14,6 +14,7 @@ using PrivaPub.Services.ClientToServer.Public; using PrivaPub.Federation.Actors; using PrivaPub.Federation.Outbox; using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Inbox.Handlers; using PrivaPub.Domain.Content; using PrivaPub.Infrastructure.Http; @@ -50,6 +51,7 @@ namespace PrivaPub.Middleware SafeHttpHandlerFactory.Create(provider.GetRequiredService>().Value)); return service .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/PrivaPub/Models/Federation/DomainBlock.cs b/PrivaPub/Models/Federation/DomainBlock.cs new file mode 100644 index 0000000..ac86449 --- /dev/null +++ b/PrivaPub/Models/Federation/DomainBlock.cs @@ -0,0 +1,21 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Federation +{ + public class DomainBlock : Entity + { + public string Domain { get; set; } + public DomainBlockSeverity Severity { get; set; } + public bool RejectMedia { get; set; } + public string PublicComment { get; set; } + public string PrivateComment { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } + + public enum DomainBlockSeverity + { + None, + Silence, + Suspend + } +} diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index a8dfcee..3a4a35d 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -154,6 +154,7 @@ try var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities; var passwordHasher = app.Services.GetService(typeof(IPasswordHasher)) as IPasswordHasher; await dbClient.Init(passwordHasher); + await app.Services.GetRequiredService().Reload(CancellationToken.None); } catch (Exception ex) {