M2: every inbox answer is recorded

InboxReceiver records each answer once, in a finally, with a reason: too-large, not-json,
not-activity, missing-type-or-actor, no-signature, the signature check's own codes
(headers-unsigned, digest-mismatch, header-unreadable, date-skew, expired,
algorithm-unsupported), signature-invalid, actor-not-key-owner, key-unavailable,
id-cross-origin, undo-foreign, misattributed, unknown-recipient, and for 202s queued,
duplicate, suspended or self-delete-unknown-key. Each event carries the activity and object
type, the inbox, the signature scheme, the bytes and the time taken. A 404 for an unknown
/mouth and a rate-limited inbox (429, from OnRejected) are recorded too.

Until the signature verifies, the host is only claimed, so it is kept only if the server is
already known. A suspended server is recorded under its own name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 10:59:55 +02:00
1 parent 15cd034b29
commit 4fa53f63bd
7 files changed
+385 -24

No files matched your search

@@ -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<DomainBlocks>.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<InboxReceiver>.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);
}
}
}
@@ -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<List<BsonDocument>> StoredSince(DateTime since, CancellationToken token)
{
await _host.Get<InteractionLedger>().Flush(token);
return await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent))
.Find(Builders<BsonDocument>.Filter.Gte(nameof(InteractionEvent.At), since) & Builders<BsonDocument>.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);
}
}
}
+2 -1
View File
@@ -51,7 +51,7 @@ namespace PrivaPub.Tests.Support
RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews()); RemotePosts = new RemotePosts(Db, Local, Remote, new NoBlocks(), Queue, Records, new NoPreviews());
Outbox = new OutboxPublisher(Db, Local, Delivery); Outbox = new OutboxPublisher(Db, Local, Delivery);
Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox); Quotes = new QuoteService(Db, Remote, RemotePosts, Local, Delivery, Outbox);
Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance); Receiver = new InboxReceiver(Local, Remote, Queue, new NoBlocks(), NullLogger<InboxReceiver>.Instance, Ledger);
Handlers = new IActivityHandler[] Handlers = new IActivityHandler[]
{ {
new FollowHandler(Db, Local, Remote, Delivery), new FollowHandler(Db, Local, Remote, Delivery),
@@ -83,6 +83,7 @@ namespace PrivaPub.Tests.Support
public Peer Peer { get; } public Peer Peer { get; }
public DbEntities Db { get; } = new(); public DbEntities Db { get; } = new();
public MemoryLedger Ledger { get; } = new();
public JobQueue Queue { get; } = new(); public JobQueue Queue { get; } = new();
public LocalActorService Local { get; } public LocalActorService Local { get; }
public RemoteActorService Remote { get; } public RemoteActorService Remote { get; }
@@ -248,7 +248,7 @@ namespace PrivaPub.Federation.Controllers
{ {
var local = await _localActors.FindByUserName(actor, token); var local = await _localActors.FindByUserName(actor, token);
if (local is not { IsFederated: true }) if (local is not { IsFederated: true })
return NotFound(); return Answer(_inbox.NoSuchRecipient(Request));
return Answer(await _inbox.Receive(Request, local, token)); return Answer(await _inbox.Receive(Request, local, token));
} }
@@ -359,7 +359,7 @@ namespace PrivaPub.Federation.Controllers
IActionResult Answer(InboxResult result) IActionResult Answer(InboxResult result)
{ {
if (result.Error != default) 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) if (result.RetryAfterSeconds is { } seconds)
Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture); Response.Headers.RetryAfter = seconds.ToString(System.Globalization.CultureInfo.InvariantCulture);
return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error); return result.Error == default ? StatusCode(result.StatusCode) : StatusCode(result.StatusCode, result.Error);
+94 -21
View File
@@ -3,8 +3,10 @@ using PrivaPub.Federation.Moderation;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Jobs; using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Federation; using PrivaPub.Models.Federation;
using PrivaPub.Models.Jobs; using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
@@ -13,7 +15,7 @@ using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Inbox 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, public sealed record InboxPayload(string ActorURI, string Activity, string Inbox = default, string KeyId = default, string Algorithm = default,
string[] SignedHeaders = default, DateTime? ReceivedAt = default); string[] SignedHeaders = default, DateTime? ReceivedAt = default);
@@ -21,6 +23,7 @@ namespace PrivaPub.Federation.Inbox
public interface IInboxReceiver public interface IInboxReceiver
{ {
Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token); Task<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token);
InboxResult NoSuchRecipient(HttpRequest request);
} }
public class InboxReceiver : IInboxReceiver public class InboxReceiver : IInboxReceiver
@@ -34,27 +37,87 @@ namespace PrivaPub.Federation.Inbox
readonly IJobQueue _queue; readonly IJobQueue _queue;
readonly IDomainBlocks _domainBlocks; readonly IDomainBlocks _domainBlocks;
readonly ILogger<InboxReceiver> _logger; readonly ILogger<InboxReceiver> _logger;
readonly IInteractionLedger _ledger;
public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks, public InboxReceiver(ILocalActorService localActors, IRemoteActorService remoteActors, IJobQueue queue, IDomainBlocks domainBlocks,
ILogger<InboxReceiver> logger) ILogger<InboxReceiver> logger, IInteractionLedger ledger = default)
{ {
_localActors = localActors; _localActors = localActors;
_remoteActors = remoteActors; _remoteActors = remoteActors;
_queue = queue; _queue = queue;
_domainBlocks = domainBlocks; _domainBlocks = domainBlocks;
_logger = logger; _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<InboxResult> Receive(HttpRequest request, LocalActor recipient, CancellationToken token) public async Task<InboxResult> 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<InboxResult> Receive(HttpRequest request, LocalActor recipient, Receipt receipt, CancellationToken token)
{ {
if (request.ContentLength > MaxBodyBytes) if (request.ContentLength > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge); return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
using var buffer = new MemoryStream(); using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer, token); await request.Body.CopyToAsync(buffer, token);
if (buffer.Length > MaxBodyBytes) if (buffer.Length > MaxBodyBytes)
return new(StatusCodes.Status413PayloadTooLarge); return new(StatusCodes.Status413PayloadTooLarge, Reason: "too-large");
var body = buffer.ToArray(); var body = buffer.ToArray();
receipt.Bytes = body.Length;
JsonNode activity; JsonNode activity;
try try
@@ -63,41 +126,51 @@ namespace PrivaPub.Federation.Inbox
} }
catch (JsonException) 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) 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 type = Value(activity, "type");
var actorUri = Id(activity["actor"]); 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)) 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()); var parameters = HttpSignatures.Parse(request.Headers["Signature"].ToString());
if (parameters == default) 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))) 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); var requestProblem = HttpSignatures.CheckRequest(request, parameters, body);
if (requestProblem != default) if (requestProblem != default)
return new(StatusCodes.Status401Unauthorized, requestProblem); return new(StatusCodes.Status401Unauthorized, requestProblem, Reason: HttpSignatures.ProblemCode(requestProblem));
var signingString = HttpSignatures.SigningString(request, parameters); var signingString = HttpSignatures.SigningString(request, parameters);
var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token); var keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: false, token);
if (keyOwner == default && type == "Delete" && Id(activity["object"]) == actorUri) 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)) if (keyOwner == default || !HttpSignatures.Verify(keyOwner.PublicKey, signingString, parameters.Signature))
{ {
keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token); keyOwner = await _remoteActors.GetActorByKeyId(parameters.KeyId, refresh: true, token);
if (keyOwner == default && _remoteActors.KeyTemporarilyUnavailable(parameters.KeyId)) 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)) 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)) 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); var shapeProblem = await ShapeProblem(type, activity, actorUri, token);
if (shapeProblem != default) if (shapeProblem != default)
@@ -106,19 +179,19 @@ namespace PrivaPub.Federation.Inbox
var activityId = Id(activity); var activityId = Id(activity);
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId, var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", parameters.KeyId,
parameters.Algorithm, parameters.Headers, DateTime.UtcNow); 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); new Uri(actorUri).Host.ToLowerInvariant(), activityId == default ? default : "inbox|" + activityId, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri); _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<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token) async Task<InboxResult> ShapeProblem(string type, JsonNode activity, string actorUri, CancellationToken token)
{ {
var activityId = Id(activity); var activityId = Id(activity);
if (activityId != default && !Origin.Same(activityId, actorUri)) 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"]; var inner = activity["object"];
switch (type) switch (type)
@@ -126,14 +199,14 @@ namespace PrivaPub.Federation.Inbox
case "Follow": case "Follow":
var target = await _localActors.FindByUri(Id(inner), token); var target = await _localActors.FindByUri(Id(inner), token);
if (target is not { IsFederated: true } || target.Kind == LocalActorKind.Application) 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; break;
case "Undo" when inner is JsonObject && Id(inner["actor"]) != actorUri: 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) case "Create" or "Update" when inner is JsonObject && Origin.Same(Id(inner), actorUri)
&& inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri && inner["attributedTo"] != default && Id(inner["attributedTo"]) != actorUri
&& Value(inner, "type") is not ("Person" or "Service" or "Application" or "Group" or "Organization"): && 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; return default;
} }
@@ -185,6 +185,18 @@ namespace PrivaPub.Federation.Signing
return default; 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) => static bool IsFresh(DateTimeOffset signedAt, DateTimeOffset now) =>
signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew; signedAt >= now - MaxAge && signedAt <= now + MaxClockSkew;
+21
View File
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.RateLimiting;
using PrivaPub.Federation.Objects; using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Signing; using PrivaPub.Federation.Signing;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Statistics;
using System.Threading.RateLimiting; using System.Threading.RateLimiting;
@@ -16,6 +18,25 @@ namespace PrivaPub.Infrastructure
service.AddRateLimiter(options => service.AddRateLimiter(options =>
{ {
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = (context, _) =>
{
var http = context.HttpContext;
var ledger = http.RequestServices.GetService<IInteractionLedger>();
var policy = http.GetEndpoint()?.Metadata.GetMetadata<EnableRateLimitingAttribute>()?.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( options.AddPolicy(Accounts, context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown", context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));