Forwarded activities are believed as far as their origin vouches

A thread's server passes on what happens in it, signed with its own key: Mastodon forwards the replies to its
accounts' posts and their deletions, Friendica every activity in its threads. PrivaPub answered them 401, which also
tells a sender its signature failed. Now they get 202 and nothing in them is believed: a forwarded Create or Update is
taken as its object reads at the actor's origin, a Delete of a public or unlisted copy once that origin answers 404 or
410 (RemoteActorService.IsGone; FederationHttp remembers the status of a refusal), anything else is let go, and our own
activities coming back are ignored. A forwarded copy has its own dedupe key, so one that failed never hides the
author's own delivery.

A reply in the thread of someone followed here is kept, as Mastodon keeps them. Mastodon delivers a reply to the
followers of the account it answers; PrivaPub dropped those as unaddressed, which the pasture showed: the outsider's
reply its Mastodon scenario said was never delivered had been, and was thrown away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-05 09:34:08 +02:00
1 parent 01808fa644
commit 7eb7c017a5
12 files changed
+334 -21

No files matched your search

@@ -20,6 +20,7 @@ namespace PrivaPub.Federation.Actors
Task<ForeignAvatar> GetActor(string actorUri, bool refresh, CancellationToken token);
Task<ForeignAvatar> GetActorByKeyId(string keyId, bool refresh, CancellationToken token);
bool KeyTemporarilyUnavailable(string keyId) => false;
Task<bool> IsGone(string uri, CancellationToken token) => Task.FromResult(false);
Task<string> ResolveHandle(string handle, CancellationToken token);
}
@@ -138,6 +139,18 @@ namespace PrivaPub.Federation.Actors
public bool KeyTemporarilyUnavailable(string keyId) => Origin.Of(keyId) != default && _http.FailedTemporarily(StripFragment(keyId));
// whether an object's origin says it is gone: 404 or 410 to our instance actor, or a Tombstone in its place
public async Task<bool> IsGone(string uri, CancellationToken token)
{
if (Origin.Of(uri) == default)
return false;
using var scope = HttpScope.Default("object");
var signer = await _localActors.GetInstanceActor(token);
var (status, fetched) = await _http.GetJsonStatus(uri, Accept, request => HttpSignatures.Sign(request, signer, body: null), token);
using (fetched)
return status is StatusCodes.Status404NotFound or StatusCodes.Status410Gone || fetched != default && Text(fetched.Root, "type") == "Tombstone";
}
public async Task<string> ResolveHandle(string handle, CancellationToken token)
{
var parts = handle?.TrimStart('@').Split('@');
+71
View File
@@ -0,0 +1,71 @@
using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Objects;
using PrivaPub.Models.Post;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Federation.Inbox
{
// Inbox forwarding (ActivityPub 7.1.2): a server passes on an activity about a thread it holds to the thread's followers,
// signed with its own key. Mastodon forwards the replies to its accounts' posts, and their deletions; Friendica every
// activity in its threads. The signature proves who passed it on, never who wrote it, so nothing in it is believed: a
// Create or an Update is taken as its object reads at the actor's origin now, a Delete once that origin says the object
// is gone, and anything else is let go (a vote or a follow cannot be checked against its origin). An LD signature
// (RsaSignature2017) would prove the author, but needs JSON-LD; integrity proofs (FEP-8b32) will.
public static class Forwarded
{
// what may be taken from a forwarder: a post of the activity's actor, created, edited or deleted
public static bool Takeable(string type, JsonNode activity, string actorUri)
{
var objectUri = Id(activity["object"]);
return type is "Create" or "Update" or "Delete" && objectUri != default && objectUri != actorUri && Origin.Same(objectUri, actorUri);
}
// the activity as its origin vouches for it, or why it is dropped
public static async Task<(JsonNode Activity, string Drop)> Confirm(JsonNode activity, string type, string actorUri,
IRemoteActorService remoteActors, CancellationToken token)
{
var objectUri = Id(activity["object"]);
var trusted = new JsonObject
{
["id"] = Id(activity),
["type"] = type,
["actor"] = actorUri
};
if (type == "Delete")
{
// only a copy anyone may read: the origin answers 404 for a followers-only post to our instance actor too,
// and the author's own server tells its recipients of a deletion
var held = await DB.Default.Find<PostEntity>().Match(p => p.ObjectURI == objectUri && p.ActorURI == actorUri).ExecuteFirstAsync(token);
if (held != default && held.Visibility is not (PostVisibility.Public or PostVisibility.Unlisted))
return (default, "forwarded-private");
if (!await remoteActors.IsGone(objectUri, token))
return (default, "forwarded-not-gone");
trusted["object"] = objectUri;
return (trusted, default);
}
if (type == "Create")
{
// the Create handler reads it again from its origin, and records that it did
trusted["object"] = objectUri;
return (trusted, default);
}
using var fetched = await remoteActors.FetchObject(objectUri, token);
if (fetched == default)
return (default, "fetch-failed");
var node = JsonNode.Parse(fetched.Root.GetRawText());
if (!Origin.Same(Id(node), actorUri))
return (default, "cross-origin");
trusted["object"] = node;
return (trusted, default);
}
}
}
@@ -145,7 +145,10 @@ namespace PrivaPub.Federation.Inbox.Handlers
var followed = visibility != PostVisibility.Direct && await _dbEntities.Followings
.Match(f => f.TargetActorURI == author.ActorURI && f.State == FollowState.Accepted)
.ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed)
// a reply in the thread of someone followed here, as Mastodon keeps them: what the thread's server forwards
var repliesToFollowed = !followed && parent is { IsFederatedCopy: true } && visibility is PostVisibility.Public or PostVisibility.Unlisted
&& await _dbEntities.Followings.Match(f => f.TargetActorURI == parent.ActorURI && f.State == FollowState.Accepted).ExecuteAnyAsync(token);
if (visibility == PostVisibility.Direct ? persons.Count == 0 : group == default && persons.Count == 0 && !repliesToLocal && !followed && !repliesToFollowed)
{
Arrival.Drop("not-addressed");
return;
@@ -61,6 +61,16 @@ namespace PrivaPub.Federation.Inbox
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded");
}
if (payload.ForwardedBy != default)
{
var (confirmed, drop) = await Forwarded.Confirm(activity, type, actor.ActorURI, _remoteActors, token);
if (drop != default)
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, drop);
return JobOutcome.Done;
}
activity = confirmed;
}
var arrival = new Arrival(Id(activity), type, actor.ActorURI, payload.Inbox, payload.KeyId, payload.Algorithm,
payload.SignedHeaders ?? Array.Empty<string>(), payload.ReceivedAt ?? job.CreatedAt, activity["@context"]?.ToJsonString());
+17 -8
View File
@@ -18,8 +18,9 @@ namespace PrivaPub.Federation.Inbox
{
public sealed record InboxResult(int StatusCode, string Error = default, int? RetryAfterSeconds = default, string Reason = default);
// ForwardedBy: the server that passed the activity on, signing with its own key (Forwarded)
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, string ForwardedBy = default);
public interface IInboxReceiver
{
@@ -100,7 +101,8 @@ namespace PrivaPub.Federation.Inbox
Activity = receipt.Type,
Object = receipt.ObjectType,
Status = result.StatusCode,
Outcome = result.StatusCode == StatusCodes.Status202Accepted ? Interactions.Queued : Interactions.Refused,
Outcome = result.StatusCode != StatusCodes.Status202Accepted ? Interactions.Refused
: result.Reason == "forwarded-ignored" ? Interactions.Dropped : Interactions.Queued,
Reason = result.Reason,
LatencyMs = latencyMs,
Bytes = receipt.Bytes,
@@ -171,9 +173,13 @@ namespace PrivaPub.Federation.Inbox
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", Reason: "actor-not-key-owner");
receipt.VerifiedActor = actorUri;
// signed by someone else: passed on by a server that holds the thread, its signature good (a 401 would tell it
// otherwise, and make a double-knocking sender try its other scheme)
receipt.VerifiedActor = keyOwner.ActorURI;
var forwardedBy = string.Equals(keyOwner.ActorURI, actorUri, StringComparison.Ordinal) ? default : keyOwner.ActorURI;
// (ours come back too, Friendica forwarding our comments in its threads: we know them already)
if (forwardedBy != default && (!Forwarded.Takeable(type, activity, actorUri) || Origin.Same(actorUri, _localActors.BaseAddress)))
return new(StatusCodes.Status202Accepted, Reason: "forwarded-ignored");
var shapeProblem = await ShapeProblem(type, activity, actorUri, token);
if (shapeProblem != default)
@@ -181,11 +187,14 @@ namespace PrivaPub.Federation.Inbox
var activityId = Id(activity);
var payload = new InboxPayload(actorUri, activity.ToJsonString(), recipient == default ? "shared" : "personal", signature.KeyId,
signature.Algorithm, signature.Covered, DateTime.UtcNow);
signature.Algorithm, signature.Covered, DateTime.UtcNow, forwardedBy);
// a forwarded copy never stands in for the author's own delivery, which may carry what the copy could not
// (a followers-only reply our instance actor cannot read): each is kept once, apart
var dedupe = activityId == default ? default : (forwardedBy == default ? "inbox|" : "inbox|forwarded|") + activityId;
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(), dedupe, token);
_logger.LogInformation("Inbox {Recipient}: {Type} from {Actor} queued", recipient?.Handle ?? "shared", type, actorUri);
return new(StatusCodes.Status202Accepted, Reason: queued ? "queued" : "duplicate");
return new(StatusCodes.Status202Accepted, Reason: !queued ? "duplicate" : forwardedBy != default ? "forwarded" : "queued");
}
static string HostOf(string uri) => Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? parsed.Host.ToLowerInvariant() : default;
+23 -2
View File
@@ -23,6 +23,7 @@ namespace PrivaPub.Infrastructure.Http
{
bool IsAllowed(Uri target);
Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token);
bool FailedTemporarily(string url);
Task<(Uri FinalUri, string Html)> GetPage(string url, CancellationToken token);
Task<HttpResponseMessage> OpenMedia(string url, System.Net.Http.Headers.RangeHeaderValue range, CancellationToken token);
@@ -295,6 +296,24 @@ namespace PrivaPub.Infrastructure.Http
}
}
// the document and the status its origin answered with (a refusal remembered from the last five minutes keeps its
// status; 0 when it never answered)
public async Task<(int Status, FetchedJson Json)> GetJsonStatus(string url, string accept, Action<HttpRequestMessage> sign, CancellationToken token)
{
if (Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out Refusal remembered))
return (remembered.Status, default);
var exchange = new Exchange(url, HttpScope.Purpose ?? "object");
try
{
var json = await GetJson(url, accept, sign, exchange, token);
return (exchange.Status ?? 0, json);
}
finally
{
Record(exchange);
}
}
async Task<FetchedJson> GetJson(string url, string accept, Action<HttpRequestMessage> sign, Exchange exchange, CancellationToken token)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var target) || !IsAllowed(target))
@@ -367,7 +386,7 @@ namespace PrivaPub.Infrastructure.Http
}
public bool FailedTemporarily(string url) =>
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out bool transient) && transient;
Uri.TryCreate(url, UriKind.Absolute, out var target) && _cache.TryGetValue(NegativeKey(target), out Refusal refusal) && refusal.Transient;
public async Task<(byte[] Bytes, string ContentType)> GetMedia(string url, long maxBytes, CancellationToken token)
{
@@ -610,13 +629,15 @@ namespace PrivaPub.Infrastructure.Http
static string NegativeKey(Uri target) => "federation-http:refused:" + target.AbsoluteUri;
sealed record Refusal(bool Transient, int Status);
FetchedJson Refuse(string negativeKey, string url, string reason, Exchange exchange, string code, bool transient = false)
{
if (transient)
exchange.Failed(code);
else
exchange.Refused(code);
_cache.Set(negativeKey, transient, NegativeCacheLifetime);
_cache.Set(negativeKey, new Refusal(transient, exchange.Status ?? 0), NegativeCacheLifetime);
_logger.LogInformation("GET {Url} refused: {Reason}", url, reason);
return default;
}