[backend] Implement federation control (ISH-2)

This commit is contained in:
Laura Hausmann 2024-02-07 17:52:41 +01:00
parent e59053cdcb
commit 5978f1abc4
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
15 changed files with 6203 additions and 35 deletions

View file

@ -37,9 +37,12 @@ public sealed class Config {
}
public sealed class SecuritySection {
public bool AuthorizedFetch { get; init; } = true;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public bool AuthorizedFetch { get; init; } = true;
public ExceptionVerbosity ExceptionVerbosity { get; init; } = ExceptionVerbosity.Basic;
public Enums.Registrations Registrations { get; init; } = Enums.Registrations.Closed;
public Enums.FederationMode FederationMode { get; init; } = Enums.FederationMode.BlockList;
public Enums.ItemVisibility ExposeFederationList { get; init; } = Enums.ItemVisibility.Registered;
public Enums.ItemVisibility ExposeBlockReasons { get; init; } = Enums.ItemVisibility.Registered;
}
public sealed class DatabaseSection {

View file

@ -1,6 +1,17 @@
namespace Iceshrimp.Backend.Core.Configuration;
public static class Enums {
public enum FederationMode {
BlockList = 0,
AllowList = 1
}
public enum ItemVisibility {
Hide = 0,
Registered = 1,
Public = 2
}
public enum Registrations {
Closed = 0,
Invite = 1,

View file

@ -79,6 +79,8 @@ public class DatabaseContext(DbContextOptions<DatabaseContext> options)
public virtual DbSet<UserPublickey> UserPublickeys { get; init; } = null!;
public virtual DbSet<UserSecurityKey> UserSecurityKeys { get; init; } = null!;
public virtual DbSet<Webhook> Webhooks { get; init; } = null!;
public virtual DbSet<AllowedInstance> AllowedInstances { get; init; } = null!;
public virtual DbSet<BlockedInstance> BlockedInstances { get; init; } = null!;
public virtual DbSet<DataProtectionKey> DataProtectionKeys { get; init; } = null!;
public static NpgsqlDataSource GetDataSource(Config.DatabaseSection? config) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Iceshrimp.Backend.Core.Database.Migrations
{
/// <inheritdoc />
public partial class AddFederationControl : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "allowed_instance",
columns: table => new
{
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
imported = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_allowed_instance", x => x.host);
});
migrationBuilder.CreateTable(
name: "blocked_instance",
columns: table => new
{
host = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
reason = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
imported = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_blocked_instance", x => x.host);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "allowed_instance");
migrationBuilder.DropTable(
name: "blocked_instance");
}
}
}

View file

@ -197,6 +197,22 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("access_token");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.AllowedInstance", b =>
{
b.Property<string>("Host")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("host");
b.Property<bool>("IsImported")
.HasColumnType("boolean")
.HasColumnName("imported");
b.HasKey("Host");
b.ToTable("allowed_instance");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Announcement", b =>
{
b.Property<string>("Id")
@ -532,6 +548,27 @@ namespace Iceshrimp.Backend.Core.Database.Migrations
b.ToTable("auth_session");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.BlockedInstance", b =>
{
b.Property<string>("Host")
.HasMaxLength(256)
.HasColumnType("character varying(256)")
.HasColumnName("host");
b.Property<bool>("IsImported")
.HasColumnType("boolean")
.HasColumnName("imported");
b.Property<string>("Reason")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("reason");
b.HasKey("Host");
b.ToTable("blocked_instance");
});
modelBuilder.Entity("Iceshrimp.Backend.Core.Database.Tables.Blocking", b =>
{
b.Property<string>("Id")

View file

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("allowed_instance")]
public class AllowedInstance {
[Key]
[Column("host")]
[StringLength(256)]
public string Host { get; set; } = null!;
[Column("imported")] public bool IsImported { get; set; }
}

View file

@ -0,0 +1,18 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Iceshrimp.Backend.Core.Database.Tables;
[Table("blocked_instance")]
public class BlockedInstance {
[Key]
[Column("host")]
[StringLength(256)]
public string Host { get; set; } = null!;
[Column("reason")]
[StringLength(1024)]
public string? Reason { get; set; } = null!;
[Column("imported")] public bool IsImported { get; set; }
}

View file

@ -1,8 +1,8 @@
using System.Threading.RateLimiting;
using Iceshrimp.Backend.Controllers.Mastodon.Renderers;
using Iceshrimp.Backend.Controllers.Schemas;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Services;
@ -21,26 +21,27 @@ public static class ServiceExtensions {
//services.AddTransient<T>();
// Scoped = instantiated per request
services.AddScoped<ActivityRenderer>();
services.AddScoped<UserRenderer>();
services.AddScoped<NoteRenderer>();
services.AddScoped<UserResolver>();
services.AddScoped<ActivityPub.ActivityRenderer>();
services.AddScoped<ActivityPub.UserRenderer>();
services.AddScoped<ActivityPub.NoteRenderer>();
services.AddScoped<ActivityPub.UserResolver>();
services.AddScoped<UserService>();
services.AddScoped<NoteService>();
services.AddScoped<ActivityDeliverService>();
services.AddScoped<ActivityHandlerService>();
services.AddScoped<ActivityPub.ActivityDeliverService>();
services.AddScoped<ActivityPub.ActivityHandlerService>();
services.AddScoped<WebFingerService>();
services.AddScoped<ActivityPub.FederationControlService>();
services.AddScoped<AuthorizedFetchMiddleware>();
services.AddScoped<AuthenticationMiddleware>();
//TODO: make this prettier
services.AddScoped<Controllers.Mastodon.Renderers.UserRenderer>();
services.AddScoped<Controllers.Mastodon.Renderers.NoteRenderer>();
services.AddScoped<UserRenderer>();
services.AddScoped<NoteRenderer>();
// Singleton = instantiated once across application lifetime
services.AddSingleton<HttpClient>();
services.AddSingleton<HttpRequestService>();
services.AddSingleton<ActivityFetcherService>();
services.AddSingleton<ActivityPub.ActivityFetcherService>();
services.AddSingleton<QueueService>();
services.AddSingleton<ErrorHandlerMiddleware>();
services.AddSingleton<RequestBufferingMiddleware>();

View file

@ -19,41 +19,56 @@ public class ActivityHandlerService(
DatabaseContext db,
QueueService queueService,
ActivityRenderer activityRenderer,
IOptions<Config.InstanceSection> config
IOptions<Config.InstanceSection> config,
FederationControlService federationCtrl
) {
public Task PerformActivityAsync(ASActivity activity, string? inboxUserId) {
public async Task PerformActivityAsync(ASActivity activity, string? inboxUserId) {
logger.LogDebug("Processing activity: {activity}", activity.Id);
if (activity.Actor == null) throw new Exception("Cannot perform activity as actor 'null'");
if (activity.Actor == null)
throw GracefulException.UnprocessableEntity("Cannot perform activity as actor 'null'");
if (await federationCtrl.ShouldBlockAsync(activity.Actor.Id))
throw GracefulException.UnprocessableEntity("Instance is blocked");
//TODO: validate inboxUserId
switch (activity.Type) {
case ASActivity.Types.Create: {
//TODO: implement the rest
if (activity.Object is ASNote note) return noteSvc.ProcessNoteAsync(note, activity.Actor);
throw GracefulException.UnprocessableEntity("Create activity object is invalid");
if (activity.Object is not ASNote note)
throw GracefulException.UnprocessableEntity("Create activity object is invalid");
await noteSvc.ProcessNoteAsync(note, activity.Actor);
return;
}
case ASActivity.Types.Follow: {
if (activity.Object is { } obj) return FollowAsync(obj, activity.Actor, activity.Id);
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
if (activity.Object is not { } obj)
throw GracefulException.UnprocessableEntity("Follow activity object is invalid");
await FollowAsync(obj, activity.Actor, activity.Id);
return;
}
case ASActivity.Types.Unfollow: {
if (activity.Object is { } obj) return UnfollowAsync(obj, activity.Actor);
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
if (activity.Object is not { } obj)
throw GracefulException.UnprocessableEntity("Unfollow activity object is invalid");
await UnfollowAsync(obj, activity.Actor);
return;
}
case ASActivity.Types.Accept: {
if (activity.Object is { } obj) return AcceptAsync(obj, activity.Actor);
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
if (activity.Object is not { } obj)
throw GracefulException.UnprocessableEntity("Accept activity object is invalid");
await AcceptAsync(obj, activity.Actor);
return;
}
case ASActivity.Types.Reject: {
if (activity.Object is { } obj) return RejectAsync(obj, activity.Actor);
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
if (activity.Object is not { } obj)
throw GracefulException.UnprocessableEntity("Reject activity object is invalid");
await RejectAsync(obj, activity.Actor);
return;
}
case ASActivity.Types.Undo: {
//TODO: implement the rest
if (activity.Object is ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
return UnfollowAsync(undoActivity.Object, activity.Actor);
throw new NotImplementedException("Unsupported undo operation");
if (activity.Object is not ASActivity { Type: ASActivity.Types.Follow, Object: not null } undoActivity)
throw new NotImplementedException("Unsupported undo operation");
await UnfollowAsync(undoActivity.Object, activity.Actor);
return;
}
default: {
throw new NotImplementedException($"Activity type {activity.Type} is unknown");

View file

@ -0,0 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Iceshrimp.Backend.Core.Federation.ActivityPub;
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor",
Justification = "We need IOptionsSnapshot for config hot reload")]
public class FederationControlService(IOptionsSnapshot<Config.SecuritySection> options, DatabaseContext db) {
//TODO: we need some level of caching here
public async Task<bool> ShouldBlockAsync(params string[] hosts) {
hosts = hosts.Select(p => p.StartsWith("http://") || p.StartsWith("https://") ? new Uri(p).Host : p)
.Select(p => p.ToPunycode())
.ToArray();
// We want to check for fully qualified domains *and* subdomains of them
if (options.Value.FederationMode == Enums.FederationMode.AllowList) {
return !await db.AllowedInstances.AnyAsync(p => hosts.Any(host => host == p.Host ||
host.EndsWith("." + p.Host)));
}
return await db.BlockedInstances.AnyAsync(p => hosts.Any(host => host == p.Host ||
host.EndsWith("." + p.Host)));
}
}

View file

@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis;
using System.Net;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Services;
using Microsoft.EntityFrameworkCore;
@ -14,8 +13,9 @@ public class AuthorizedFetchMiddleware(
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
IOptionsSnapshot<Config.SecuritySection> config,
DatabaseContext db,
UserResolver userResolver,
ActivityPub.UserResolver userResolver,
UserService userSvc,
ActivityPub.FederationControlService fedCtrlSvc,
ILogger<AuthorizedFetchMiddleware> logger) : IMiddleware {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) {
var attribute = ctx.GetEndpoint()?.Metadata.GetMetadata<AuthorizedFetchAttribute>();
@ -36,17 +36,24 @@ public class AuthorizedFetchMiddleware(
var sig = HttpSignature.Parse(sigHeader.ToString());
// First, we check if we already have the key
var key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.KeyId == sig.KeyId);
var key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.KeyId == sig.KeyId);
// If we don't, we need to try to fetch it
if (key == null) {
var user = await userResolver.ResolveAsync(sig.KeyId);
key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.User == user);
key = await db.UserPublickeys.Include(p => p.User).FirstOrDefaultAsync(p => p.User == user);
}
// If we still don't have the key, something went wrong and we need to throw an exception
if (key == null) throw new GracefulException("Failed to fetch key of signature user");
if (key.User.Host == null)
throw new GracefulException("Remote user must have a host");
// We want to check both the user host & the keyId host (as account & web domain might be different)
if (await fedCtrlSvc.ShouldBlockAsync(key.User.Host, key.KeyId))
throw GracefulException.Forbidden("Instance is blocked");
List<string> headers = request.ContentLength > 0 || attribute.ForceBody
? ["(request-target)", "digest", "host", "date"]
: ["(request-target)", "host", "date"];

View file

@ -20,6 +20,12 @@ public class DeliverQueue {
var httpRqSvc = scope.GetRequiredService<HttpRequestService>();
var cache = scope.GetRequiredService<IDistributedCache>();
var db = scope.GetRequiredService<DatabaseContext>();
var fedCtrl = scope.GetRequiredService<ActivityPub.FederationControlService>();
if (await fedCtrl.ShouldBlockAsync(job.InboxUrl)) {
logger.LogDebug("Refusing to deliver activity to blocked instance ({uri})", job.InboxUrl);
return;
}
logger.LogDebug("Delivering activity to: {uri}", job.InboxUrl);

View file

@ -1,4 +1,3 @@
using Iceshrimp.Backend.Core.Federation.ActivityPub;
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Middleware;
@ -27,8 +26,9 @@ public class InboxQueue {
throw new GracefulException("Job data is not an ASActivity", $"Type: {obj.Type}");
}
var apHandler = scope.GetRequiredService<ActivityHandlerService>();
var apHandler = scope.GetRequiredService<ActivityPub.ActivityHandlerService>();
var logger = scope.GetRequiredService<ILogger<InboxQueue>>();
logger.LogTrace("Preparation took {ms} ms", job.Duration);
await apHandler.PerformActivityAsync(activity, job.InboxUserId);
}

View file

@ -8,6 +8,7 @@ AccountDomain = example.org
[Security]
;; Whether to require incoming ActivityPub requests carry a valid HTTP or LD signature
;; It is highly recommend you keep this enabled if you intend to use block- or allowlist federation
AuthorizedFetch = true
;; The level of detail in API error responses
@ -18,6 +19,18 @@ ExceptionVerbosity = Basic
;; Options: [Closed, Invite, Open]
Registrations = Closed
;; Whether to use a blocklist or allowlist for controlling who can federate with this instance
;; Options: [BlockList, AllowList]
FederationMode = BlockList
;; Whether to expose the list of blocked/allowed instances publicly, for registered users only, or not at all
;; Options = [Public, Registered, Hide]
ExposeFederationList = Registered
;; Whether to expose the reason for instance blocks publicly, for registered users only, or not at all
;; Options = [Public, Registered, Hide]
ExposeBlockReasons = Registered
[Database]
Host = localhost
Port = 5432