diff --git a/PrivaPub.Tests/Statistics/InboxAnswerTests.cs b/PrivaPub.Tests/Statistics/InboxAnswerTests.cs new file mode 100644 index 0000000..49de003 --- /dev/null +++ b/PrivaPub.Tests/Statistics/InboxAnswerTests.cs @@ -0,0 +1,169 @@ +using Microsoft.Extensions.Logging.Abstractions; + +using PrivaPub.Federation.Inbox; +using PrivaPub.Federation.Moderation; +using PrivaPub.Federation.Signing; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Statistics; +using PrivaPub.Tests.Support; + +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Statistics +{ + public class SignatureProblemCodeTests + { + [Theory] + [InlineData("unsupported signature algorithm 'ed25519'", "algorithm-unsupported")] + [InlineData("(request-target) is not signed", "headers-unsigned")] + [InlineData("host is not signed", "headers-unsigned")] + [InlineData("the digest is not signed", "headers-unsigned")] + [InlineData("neither date nor (created) is signed", "headers-unsigned")] + [InlineData("the digest does not match the body", "digest-mismatch")] + [InlineData("unreadable Date header", "header-unreadable")] + [InlineData("unreadable (created)", "header-unreadable")] + [InlineData("unreadable (expires)", "header-unreadable")] + [InlineData("the Date header is outside the allowed window", "date-skew")] + [InlineData("(created) is outside the allowed window", "date-skew")] + [InlineData("the signature has expired", "expired")] + [InlineData("something new", "signature-problem")] + public void Every_signature_problem_has_a_reason_code(string problem, string code) => + Assert.Equal(code, HttpSignatures.ProblemCode(problem)); + } + + [Trait("Category", "Integration")] + public sealed class InboxAnswerTests : IAsyncLifetime + { + Harness _harness; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _harness = await Harness.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_harness != default) + await _harness.DisposeAsync(); + } + + JsonObject Create(RemoteActor sender, string to) + { + var origin = new Uri(sender.Id).GetLeftPart(UriPartial.Authority); + var id = $"{origin}/notes/{Guid.NewGuid():N}"; + return new JsonObject + { + ["id"] = id + "/activity", + ["type"] = "Create", + ["actor"] = sender.Id, + ["to"] = new JsonArray(to), + ["object"] = new JsonObject { ["id"] = id, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = new JsonArray(to), ["content"] = "hi" } + }; + } + + (InboxResult Result, InteractionEvent Event, string ActorUri, bool Claimed) Last(InboxResult result) + { + var recorded = _harness.Ledger.Events.Last(e => e.Event.Channel == "recv"); + return (result, recorded.Event, recorded.ActorUri, recorded.HostClaimed); + } + + [Fact] + public async Task A_verified_delivery_is_queued_once_and_then_a_duplicate() + { + var (_, alice) = await _harness.Persona("alice"); + var bob = new RemoteActor(_harness.Peer, "bob"); + var activity = Create(bob, alice.Uri); + + var first = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None)); + var second = Last(await _harness.Receiver.Receive(bob.Post(Harness.Host, "/human-centipede", activity), default, CancellationToken.None)); + + Assert.Equal(202, first.Event.Status); + Assert.Equal("queued", first.Event.Reason); + Assert.Equal("queued", first.Event.Outcome); + Assert.Equal("127.0.0.1", first.Event.Host); + Assert.Equal("Create", first.Event.Activity); + Assert.Equal("Note", first.Event.Object); + Assert.Equal("shared", first.Event.Inbox); + Assert.Equal("cavage:rsa-sha256", first.Event.Signature); + Assert.True(first.Event.Bytes > 0); + Assert.Equal(bob.Id, first.ActorUri); + Assert.False(first.Claimed); + Assert.Equal("duplicate", second.Event.Reason); + } + + [Fact] + public async Task Refusals_carry_their_reason_and_an_unverified_host_is_only_claimed() + { + var (_, alice) = await _harness.Persona("alice"); + var mallory = new RemoteActor(_harness.Peer, "mallory"); + + var unsigned = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); + unsigned.Headers.Remove("Signature"); + var noSignature = Last(await _harness.Receiver.Receive(unsigned, default, CancellationToken.None)); + + var tampered = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); + tampered.Headers["Digest"] = "SHA-256=AAAA"; + var badDigest = Last(await _harness.Receiver.Receive(tampered, default, CancellationToken.None)); + + var forged = mallory.Post(Harness.Host, "/human-centipede", Create(mallory, alice.Uri)); + forged.Headers["Signature"] = forged.Headers["Signature"].ToString().Replace("signature=\"", "signature=\"AAAA"); + var badSignature = Last(await _harness.Receiver.Receive(forged, default, CancellationToken.None)); + + var junk = Last(await _harness.Deliver(mallory, "/human-centipede", new JsonArray(1, 2))); + + Assert.Equal((401, "no-signature", "none"), (noSignature.Event.Status!.Value, noSignature.Event.Reason, noSignature.Event.Signature)); + Assert.Equal((401, "digest-mismatch"), (badDigest.Event.Status!.Value, badDigest.Event.Reason)); + Assert.Equal((401, "signature-invalid"), (badSignature.Event.Status!.Value, badSignature.Event.Reason)); + Assert.Equal((400, "not-activity"), (junk.Event.Status!.Value, junk.Event.Reason)); + foreach (var refused in new[] { noSignature, badDigest, badSignature }) + { + Assert.Equal("refused", refused.Event.Outcome); + Assert.True(refused.Claimed); + Assert.Null(refused.ActorUri); + } + } + + [Fact] + public async Task A_follow_of_nobody_is_a_404_and_a_self_delete_with_an_unknown_key_is_a_silent_202() + { + var stranger = new RemoteActor(_harness.Peer, "stranger"); + var follow = new JsonObject + { + ["id"] = stranger.Id + "/follows/" + Guid.NewGuid().ToString("N"), + ["type"] = "Follow", + ["actor"] = stranger.Id, + ["object"] = $"{Harness.Base}/peasants/nobody{Guid.NewGuid():N}" + }; + var notFound = Last(await _harness.Receiver.Receive(stranger.Post(Harness.Host, "/human-centipede", follow), default, CancellationToken.None)); + + var gone = new RemoteActor(_harness.Peer, "gone"); + _harness.Peer.Answer(new Uri(gone.Id).AbsolutePath, 410); + var delete = new JsonObject { ["id"] = gone.Id + "#delete", ["type"] = "Delete", ["actor"] = gone.Id, ["object"] = gone.Id }; + var deleted = Last(await _harness.Receiver.Receive(gone.Post(Harness.Host, "/human-centipede", delete), default, CancellationToken.None)); + + Assert.Equal((404, "unknown-recipient"), (notFound.Event.Status!.Value, notFound.Event.Reason)); + Assert.Equal((202, "self-delete-unknown-key"), (deleted.Event.Status!.Value, deleted.Event.Reason)); + Assert.True(deleted.Claimed); + } + + [Fact] + public async Task A_suspended_server_is_recorded_under_its_own_name() + { + var blocks = new DomainBlocks(NullLogger.Instance); + blocks.Load(new[] { new DomainBlock { Domain = "localhost", Severity = DomainBlockSeverity.Suspend } }); + var ledger = new MemoryLedger(); + var receiver = new InboxReceiver(_harness.Local, _harness.Remote, _harness.Queue, blocks, NullLogger.Instance, ledger); + var (_, alice) = await _harness.Persona("alice"); + var spammer = new RemoteActor(_harness.Peer, "spammer", _harness.Peer.B); + + var result = await receiver.Receive(spammer.Post(Harness.Host, "/human-centipede", Create(spammer, alice.Uri)), default, CancellationToken.None); + + Assert.Equal(202, result.StatusCode); + var (recorded, actorUri, claimed) = Assert.Single(ledger.Events); + Assert.Equal(("localhost", "suspended", "queued"), (recorded.Host, recorded.Reason, recorded.Outcome)); + Assert.False(claimed); + Assert.Null(actorUri); + } + } +} diff --git a/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs b/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs new file mode 100644 index 0000000..698446c --- /dev/null +++ b/PrivaPub.Tests/Statistics/LedgerOverHttpTests.cs @@ -0,0 +1,85 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Infrastructure.Statistics; +using PrivaPub.Models.Statistics; +using PrivaPub.Tests.Support; +using PrivaPub.Tests.Support.Host; + +using System.Net; +using System.Text.Json.Nodes; + +namespace PrivaPub.Tests.Statistics +{ + [Trait("Category", "Integration")] + public sealed class LedgerOverHttpTests : IAsyncLifetime + { + PrivaPubHost _host; + Peer _peer; + + public async ValueTask InitializeAsync() + { + Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip); + _host = await PrivaPubHost.Shared(); + _peer = await Peer.Start(); + } + + public async ValueTask DisposeAsync() + { + if (_peer != default) + await _peer.DisposeAsync(); + } + + JsonObject DirectNote(RemoteActor sender, Persona persona, string text) + { + var noteId = $"{_peer.A}/notes/{Guid.NewGuid():N}"; + var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}"); + return new JsonObject + { + ["id"] = noteId + "/activity", + ["type"] = "Create", + ["actor"] = sender.Id, + ["to"] = to.DeepClone(), + ["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = sender.Id, ["to"] = to.DeepClone(), ["content"] = text } + }; + } + + async Task> StoredSince(DateTime since, CancellationToken token) + { + await _host.Get().Flush(token); + return await DB.Default.Database().GetCollection(nameof(InteractionEvent)) + .Find(Builders.Filter.Gte(nameof(InteractionEvent.At), since) & Builders.Filter.In(nameof(InteractionEvent.Host), new[] { "127.0.0.1", Interactions.Unknown })) + .ToListAsync(token); + } + + [Fact] + public async Task A_delivery_and_its_refusals_are_recorded_naming_only_the_server() + { + var token = TestContext.Current.CancellationToken; + var since = DateTime.UtcNow.AddSeconds(-1); + var persona = await _host.Persona(await _host.SignUp(), "ledger"); + var bob = new RemoteActor(_peer, "bob"); + using var client = _host.Client(); + + Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", DirectNote(bob, persona, "psst")), token)).StatusCode); + var forged = bob.SignedPost("/human-centipede", DirectNote(bob, persona, "forged")); + forged.Headers.Remove("Signature"); + forged.Headers.TryAddWithoutValidation("Signature", bob.SignedPost("/elsewhere", new JsonObject()).Headers.GetValues("Signature").Single()); + Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(forged, token)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.SendAsync(bob.SignedPost($"/peasants/nobody{Guid.NewGuid():N}"[..20] + "/mouth", DirectNote(bob, persona, "lost")), token)).StatusCode); + + var stored = await StoredSince(since, token); + var reasons = stored.Select(e => e.GetValue(nameof(InteractionEvent.Reason), BsonNull.Value).ToString()).ToList(); + Assert.Contains("queued", reasons); + Assert.Contains("signature-invalid", reasons); + Assert.Contains("unknown-recipient", reasons); + var everything = string.Join("\n", stored.Select(e => e.ToJson())); + Assert.DoesNotContain(persona.UserName, everything); + Assert.DoesNotContain(persona.Id, everything); + Assert.DoesNotContain(persona.Root.Id, everything); + Assert.DoesNotContain(bob.Name, everything); + Assert.DoesNotContain("/notes/", everything); + } + } +} diff --git a/PrivaPub.Tests/Support/Harness.cs b/PrivaPub.Tests/Support/Harness.cs index 24e7111..faca9f5 100644 --- a/PrivaPub.Tests/Support/Harness.cs +++ b/PrivaPub.Tests/Support/Harness.cs @@ -51,7 +51,7 @@ namespace PrivaPub.Tests.Support RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); Outbox = new OutboxPublisher(Db, Local, Delivery); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); - Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance); + Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger.Instance, Ledger); Handlers = new IActivityHandler[] { new FollowHandler(Db, Local, Remote, Delivery), @@ -83,6 +83,7 @@ namespace PrivaPub.Tests.Support public Peer Peer { get; } public DbEntities Db { get; } = new(); + public MemoryLedger Ledger { get; } = new(); public JobQueue Queue { get; } = new(); public LocalActorService Local { get; } public RemoteActorService Remote { get; } diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 646d3fe..b2b6767 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -248,7 +248,7 @@ namespace PrivaPub.Federation.Controllers { var local = await _localActors.FindByUserName(actor, token); if (local is not { IsFederated: true }) - return NotFound(); + return Answer(_inbox.NoSuchRecipient(Request)); return Answer(await _inbox.Receive(Request, local, token)); } @@ -359,7 +359,7 @@ namespace PrivaPub.Federation.Controllers IActionResult Answer(InboxResult result) { if (result.Error != default) - _logger.LogInformation("Inbox refused with {Status}: {Error}", result.StatusCode, result.Error); + _logger.LogInformation("Inbox refused with {Status} ({Reason}): {Error}", result.StatusCode, result.Reason, result.Error); if (result.RetryAfterSeconds is { } seconds) Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); diff --git a/PrivaPub/Federation/Inbox/InboxReceiver.cs b/PrivaPub/Federation/Inbox/InboxReceiver.cs index b64321a..8e6fc24 100644 --- a/PrivaPub/Federation/Inbox/InboxReceiver.cs +++ b/PrivaPub/Federation/Inbox/InboxReceiver.cs @@ -3,8 +3,10 @@ using PrivaPub.Federation.Moderation; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; using PrivaPub.Infrastructure.Jobs; +using PrivaPub.Infrastructure.Statistics; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; +using PrivaPub.Models.Statistics; using System.Text.Json; using System.Text.Json.Nodes; @@ -13,7 +15,7 @@ using static PrivaPub.Federation.Objects.ActivityJson; namespace PrivaPub.Federation.Inbox { - public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default); + public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default, string Reason = default); public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default, string[] SignedHeaders = default, DateTime? ReceivedAt = default); @@ -21,6 +23,7 @@ namespace PrivaPub.Federation.Inbox public interface IInboxReceiver { Task Receive(HttpRequest request, LocalActor recipient, CancellationToken token); + InboxResult NoSuchRecipient(HttpRequest request); } public class InboxReceiver : IInboxReceiver @@ -34,27 +37,87 @@ namespace PrivaPub.Federation.Inbox readonly IJobQueue _queue; readonly IDomainBlocks _domainBlocks; readonly ILogger _logger; + readonly IInteractionLedger _ledger; public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks, - ILogger logger) + ILogger logger, IInteractionLedger ledger = default) { _localActors = localActors; _remoteActors = remoteActors; _queue = queue; _domainBlocks = domainBlocks; _logger = logger; + _ledger = ledger; + } + + sealed class Receipt + { + public string Type; + public string ObjectType; + public string ClaimedHost; + public string VerifiedActor; + public string Inbox; + public string Signature = "none"; + public long? Bytes; + public bool Blocked; + } + + public InboxResult NoSuchRecipient(HttpRequest request) + { + var result = new InboxResult(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient"); + var keyId = HttpSignatures.Parse(request.Headers["Signature"].ToString())?.KeyId; + Record(new Receipt { ClaimedHost = HostOf(keyId), Inbox = "personal", Bytes = request.ContentLength }, result, 0); + return result; } public async Task Receive(HttpRequest request, LocalActor recipient, CancellationToken token) + { + var started = System.Diagnostics.Stopwatch.GetTimestamp(); + var receipt = new Receipt { Inbox = recipient == default ? "shared" : "personal", Bytes = request.ContentLength }; + InboxResult result = default; + try + { + result = await Receive(request, recipient, receipt, token); + return result; + } + finally + { + Record(receipt, result, (int)System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalMilliseconds); + } + } + + void Record(Receipt receipt, InboxResult result, int latencyMs) + { + if (_ledger == default || result == default) + return; + var verified = receipt.VerifiedActor != default; + _ledger.Record(new InteractionEvent + { + Channel = Interactions.Receive, + Host = verified ? Interactions.HostOf(receipt.VerifiedActor) : receipt.ClaimedHost, + Activity = receipt.Type, + Object = receipt.ObjectType, + Status = result.StatusCode, + Outcome = result.StatusCode == StatusCodes.Status202Accepted ? Interactions.Queued : Interactions.Refused, + Reason = result.Reason, + LatencyMs = latencyMs, + Bytes = receipt.Bytes, + Inbox = receipt.Inbox, + Signature = receipt.Signature + }, verified ? receipt.VerifiedActor : default, hostClaimed: !verified && !receipt.Blocked); + } + + async Task Receive(HttpRequest request, LocalActor recipient, Receipt receipt, CancellationToken token) { if (request.ContentLength > MaxBodyBytes) - return new(StatusCodes.Status413PayloadTooLarge); + return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large"); using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer, token); if (buffer.Length > MaxBodyBytes) - return new(StatusCodes.Status413PayloadTooLarge); + return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large"); var body = buffer.ToArray(); + receipt.Bytes = body.Length; JsonNode activity; try @@ -63,41 +126,51 @@ namespace PrivaPub.Federation.Inbox } catch (JsonException) { - return new(StatusCodes.Status400BadRequest, "the body is not JSON"); + return new(StatusCodes.Status400BadRequest, "the body is not JSON", Reason: "not-json"); } if (activity is not JsonObject) - return new(StatusCodes.Status400BadRequest, "the body is not an activity"); + return new(StatusCodes.Status400BadRequest, "the body is not an activity", Reason: "not-activity"); var type = Value(activity, "type"); var actorUri = Id(activity["actor"]); + receipt.Type = type; + receipt.ObjectType = activity["object"] is JsonObject embedded ? Value(embedded, "type") : default; + receipt.ClaimedHost = HostOf(actorUri); if (string.IsNullOrEmpty(type) || string.IsNullOrEmpty(actorUri)) - return new(StatusCodes.Status400BadRequest, "type and actor are required"); + return new(StatusCodes.Status400BadRequest, "type and actor are required", Reason: "missing-type-or-actor"); var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString()); if (parameters == default) - return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header"); + return new(StatusCodes.Status401Unauthorized, "missing or unreadable Signature header", Reason: "no-signature"); + receipt.Signature = "cavage:" + parameters.Algorithm; + receipt.ClaimedHost ??= HostOf(parameters.KeyId); if (_domainBlocks.IsSuspended(HostOf(parameters.KeyId)) || _domainBlocks.IsSuspended(HostOf(actorUri))) - return new(StatusCodes.Status202Accepted); + { + receipt.Blocked = true; + receipt.ClaimedHost = _domainBlocks.IsSuspended(HostOf(actorUri)) ? HostOf(actorUri) : HostOf(parameters.KeyId); + return new(StatusCodes.Status202Accepted, Reason: "suspended"); + } var requestProblem = HttpSignatures.CheckRequest(request, parameters, body); if (requestProblem != default) - return new(StatusCodes.Status401Unauthorized, requestProblem); + return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem)); var signingString = HttpSignatures.SigningString(request, parameters); var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) - return new(StatusCodes.Status202Accepted); + return new(StatusCodes.Status202Accepted, Reason: "self-delete-unknown-key"); if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) { keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId)) - return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds); + return new(StatusCodes.Status503ServiceUnavailable, "the signing key could not be fetched; try again later", KeyRetrySeconds, "key-unavailable"); if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature)) - return new(StatusCodes.Status401Unauthorized, "the signature does not verify"); + return new(StatusCodes.Status401Unauthorized, "the signature does not verify", Reason: "signature-invalid"); } if (!string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal)) - return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner"); + return new(StatusCodes.Status401Unauthorized, "the activity's actor is not the key's owner", Reason: "actor-not-key-owner"); + receipt.VerifiedActor = actorUri; var shapeProblem = await ShapeProblem(type, activity, actorUri, token); if (shapeProblem != default) @@ -106,19 +179,19 @@ namespace PrivaPub.Federation.Inbox var activityId = Id(activity); var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId, parameters.Algorithm, parameters.Headers, DateTime.UtcNow); - await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload), + var queued = await _queue.Enqueue(JobKind.ProcessInbox, JsonSerializer.Serialize(payload), new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token); _logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri); - return new(StatusCodes.Status202Accepted); + return new(StatusCodes.Status202Accepted, Reason: queued ? "queued" : "duplicate"); } - static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host : default; + static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default; async Task ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token) { var activityId = Id(activity); if (activityId != default && !Origin.Same(activityId, actorUri)) - return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin"); + return new(StatusCodes.Status400BadRequest, "the activity's id is not on its actor's origin", Reason: "id-cross-origin"); var inner = activity["object"]; switch (type) @@ -126,14 +199,14 @@ namespace PrivaPub.Federation.Inbox case "Follow": var target = await _localActors.FindByUri(Id(inner), token); if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) - return new(StatusCodes.Status404NotFound, "no such local actor"); + return new(StatusCodes.Status404NotFound, "no such local actor", Reason: "unknown-recipient"); break; case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri: - return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities"); + return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign"); case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri) && inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri && Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"): - return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor"); + return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed"); } return default; } diff --git a/PrivaPub/Federation/Signing/HttpSignatures.cs b/PrivaPub/Federation/Signing/HttpSignatures.cs index 4fc414c..55a3b9a 100644 --- a/PrivaPub/Federation/Signing/HttpSignatures.cs +++ b/PrivaPub/Federation/Signing/HttpSignatures.cs @@ -185,6 +185,18 @@ namespace PrivaPub.Federation.Signing return default; } + public static string ProblemCode(string problem) => problem switch + { + null => default, + _ when problem.StartsWith("unsupported signature algorithm", StringComparison.Ordinal) => "algorithm-unsupported", + _ when problem.EndsWith("is not signed", StringComparison.Ordinal) || problem.StartsWith("neither", StringComparison.Ordinal) => "headers-unsigned", + _ when problem.StartsWith("the digest does not match", StringComparison.Ordinal) => "digest-mismatch", + _ when problem.StartsWith("unreadable", StringComparison.Ordinal) => "header-unreadable", + _ when problem.Contains("outside the allowed window", StringComparison.Ordinal) => "date-skew", + "the signature has expired" => "expired", + _ => "signature-problem" + }; + static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) => signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew; diff --git a/PrivaPub/Infrastructure/RateLimiting.cs b/PrivaPub/Infrastructure/RateLimiting.cs index b81476f..27ce975 100644 --- a/PrivaPub/Infrastructure/RateLimiting.cs +++ b/PrivaPub/Infrastructure/RateLimiting.cs @@ -2,6 +2,8 @@ using Microsoft.AspNetCore.RateLimiting; using PrivaPub.Federation.Objects; using PrivaPub.Federation.Signing; +using PrivaPub.Infrastructure.Statistics; +using PrivaPub.Models.Statistics; using System.Threading.RateLimiting; @@ -16,6 +18,25 @@ namespace PrivaPub.Infrastructure service.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = (context, _) => + { + var http = context.HttpContext; + var ledger = http.RequestServices.GetService(); + var policy = http.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName; + if (policy == Inbox) + ledger?.Record(new InteractionEvent + { + Channel = Interactions.Receive, + Host = Interactions.HostOf(SenderOrigin(http.Request)), + Status = StatusCodes.Status429TooManyRequests, + Outcome = Interactions.Refused, + Reason = "rate-limited", + Inbox = http.Request.Path.Value?.EndsWith("/mouth", StringComparison.Ordinal) == true ? "personal" : "shared" + }, hostClaimed: true); + else + ledger?.CountServer(ServerSections.Client, $"{policy ?? "unknown"}:429"); + return ValueTask.CompletedTask; + }; options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));