An organiser's edits to a group's event follow its server
Mobilizon's organiser sends the Create, Update and Delete of an event attributed to the group, which announces the Event itself. PrivaPub refused the organiser's activities as misattributed (400) and kept the event through the group's Announce, so an edit was lost and a deletion left the event in place. An object attributed to another account of the actor's own server is now that server's to vouch for: created or edited as the server has it, under the account it is attributed to, and deleted once the server answers 404 or 410. Attributed to an account elsewhere, it is still refused. Checked against Mobilizon 5.2.4 in the pasture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
1 parent
7f6837ccb1
commit
2d293a6148
8 files changed
+102
-3
No files matched your search
@@ -178,7 +178,8 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too.
|
|||||||
- an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second
|
- an activity is queued once per id, but an id that comes back carrying another type, actor or object is a second
|
||||||
activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart;
|
activity, not a copy (Friendica's ids are `uniqid()`, which two of its processes can share), and is queued apart;
|
||||||
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
|
- the activity's `id`, and any object it creates, updates or deletes, is on the actor's origin; a cross-origin
|
||||||
object is refetched from its own origin.
|
object is refetched from its own origin. One attributed to another account of the actor's server (Mobilizon's
|
||||||
|
organiser and the group's event) is taken as that server has it, and deleted once it answers 404 or 410.
|
||||||
5. **Status codes:**
|
5. **Status codes:**
|
||||||
- bad or missing signature: **401**;
|
- bad or missing signature: **401**;
|
||||||
- malformed or forged body: **400**;
|
- malformed or forged body: **400**;
|
||||||
|
|||||||
@@ -225,6 +225,10 @@ Posts with a location (shown to nearby users of this server) never leave the ser
|
|||||||
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
|
- **Origins.** An actor document is accepted only from the address it names as its `id`. A key only if its actor lists
|
||||||
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
|
it with `owner` set to the actor and on the actor's origin. An activity's `id`, and any object it creates, updates or
|
||||||
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
deletes, must be on its actor's origin. An embedded object from another origin is fetched from that origin.
|
||||||
|
- **Another account of the same server.** An object attributed to another account of the actor's own server (Mobilizon's
|
||||||
|
organiser creates, edits and deletes the group's event) is that server's to vouch for: a creation or an edit is
|
||||||
|
taken as the server has the object, a deletion once it answers 404 or 410. Attributed to an account elsewhere, it is
|
||||||
|
refused (400).
|
||||||
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
|
- **Forwarded activities** (ActivityPub's inbox forwarding: a thread's server passing on the replies in it, signed with
|
||||||
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
|
its own key) are answered 202 and believed only as far as their origin vouches for them: a created or edited post
|
||||||
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
|
is read again from its author's server, a deletion of a public or unlisted post is applied once that server answers
|
||||||
|
|||||||
@@ -116,6 +116,50 @@ namespace PrivaPub.Tests.Federation
|
|||||||
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
|
Assert.Equal(FollowState.Accepted, (await Of(jun)).State);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobilizon: the organiser creates, edits and deletes an event attributed to the group, which announces it. They used
|
||||||
|
// to be refused as misattributed (400), so the edit was lost and the deletion left the event in place
|
||||||
|
[Fact]
|
||||||
|
public async Task An_event_its_organiser_edits_and_deletes_for_a_group_on_the_same_server_follows_that_server()
|
||||||
|
{
|
||||||
|
var token = TestContext.Current.CancellationToken;
|
||||||
|
var (_, alice) = await _harness.Persona("alice");
|
||||||
|
var group = new RemoteActor(_harness.Peer, "group", type: "Group");
|
||||||
|
var organiser = new RemoteActor(_harness.Peer, "organiser");
|
||||||
|
await Follows(alice.Id, group);
|
||||||
|
var path = $"/events/{Guid.NewGuid():N}";
|
||||||
|
JsonObject Event(string name) => new()
|
||||||
|
{
|
||||||
|
["id"] = _harness.Peer.A + path, ["type"] = "Event", ["name"] = name, ["content"] = "<p>bring bread</p>",
|
||||||
|
["attributedTo"] = group.Id, ["actor"] = organiser.Id, ["startTime"] = "2026-10-08T18:00:00Z",
|
||||||
|
["to"] = new JsonArray(PrivaPub.Federation.Objects.Addressing.Public), ["cc"] = new JsonArray(group.Id + "/followers")
|
||||||
|
};
|
||||||
|
_harness.Peer.Serve(path, Event("A picnic").ToJsonString());
|
||||||
|
Task<Post> Stored() => DB.Default.Find<Post>().Match(p => p.ObjectURI == _harness.Peer.A + path).ExecuteFirstAsync(token);
|
||||||
|
|
||||||
|
var created = await _harness.Deliver(organiser, "/human-centipede", Create(organiser, Event("A picnic")));
|
||||||
|
Assert.Equal(202, created.StatusCode);
|
||||||
|
Assert.Equal(group.Id, (await Stored()).ActorURI);
|
||||||
|
|
||||||
|
var moved = Event("A picnic, moved indoors");
|
||||||
|
moved["updated"] = DateTime.UtcNow.AddMinutes(1).ToString("O");
|
||||||
|
_harness.Peer.Serve(path, moved.ToJsonString());
|
||||||
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Update", Event("what the activity claims")));
|
||||||
|
Assert.Equal("A picnic, moved indoors", (await Stored()).Title);
|
||||||
|
|
||||||
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
|
||||||
|
Assert.NotNull(await Stored());
|
||||||
|
_harness.Peer.Answer(path, 410);
|
||||||
|
await _harness.Deliver(organiser, "/human-centipede", Activity(organiser, "Delete", JsonValue.Create(_harness.Peer.A + path)!));
|
||||||
|
Assert.Null(await Stored());
|
||||||
|
|
||||||
|
// attributed to someone of another server, it is still refused
|
||||||
|
var elsewhere = new RemoteActor(_harness.Peer, "elsewhere", _harness.Peer.B);
|
||||||
|
var claimed = Event("Someone else's");
|
||||||
|
claimed["id"] = _harness.Peer.A + $"/events/{Guid.NewGuid():N}";
|
||||||
|
claimed["attributedTo"] = elsewhere.Id;
|
||||||
|
Assert.Equal(400, (await _harness.Deliver(organiser, "/human-centipede", Create(organiser, claimed))).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
|
public async Task A_like_naming_a_post_by_its_page_counts_and_its_undo_too()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -171,12 +171,19 @@ namespace PrivaPub.Tests.Federation
|
|||||||
var token = TestContext.Current.CancellationToken;
|
var token = TestContext.Current.CancellationToken;
|
||||||
var alice = await LocalAvatar("alice");
|
var alice = await LocalAvatar("alice");
|
||||||
var mallory = new RemoteActor(_peer, "mallory");
|
var mallory = new RemoteActor(_peer, "mallory");
|
||||||
var victim = new RemoteActor(_peer, "victim");
|
var victim = new RemoteActor(_peer, "victim", _peer.B);
|
||||||
|
|
||||||
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
var result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: victim.Id));
|
||||||
|
|
||||||
Assert.Equal(400, result.StatusCode);
|
Assert.Equal(400, result.StatusCode);
|
||||||
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == victim.Id).ExecuteAnyAsync(token));
|
||||||
|
|
||||||
|
// a colleague on mallory's own server is that server's to vouch for: believed only as the server has it, and
|
||||||
|
// it has no such note
|
||||||
|
var colleague = new RemoteActor(_peer, "colleague");
|
||||||
|
result = await Deliver(mallory, $"/peasants/{alice.UserName}/mouth", DirectCreate(mallory, alice.Uri, attributedTo: colleague.Id));
|
||||||
|
Assert.Equal(202, result.StatusCode);
|
||||||
|
Assert.False(await DB.Default.Find<Post>().Match(p => p.ActorURI == colleague.Id).ExecuteAnyAsync(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
Arrival.Drop("fetch-failed");
|
Arrival.Drop("fetch-failed");
|
||||||
}
|
}
|
||||||
var note = NoteParser.Parse(node);
|
var note = NoteParser.Parse(node);
|
||||||
|
// made by another account of the author's own server (Mobilizon's organiser creates the group's event): taken
|
||||||
|
// as that server has it, under the account it is attributed to
|
||||||
|
if (note != default && note.AttributedTo != author.ActorURI && Origin.Same(note.AttributedTo, author.ActorURI))
|
||||||
|
{
|
||||||
|
if (!refetched)
|
||||||
|
{
|
||||||
|
using var fetched = await _remoteActors.FetchObject(note.Id, token);
|
||||||
|
note = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
|
||||||
|
refetched = true;
|
||||||
|
}
|
||||||
|
author = note == default ? default : await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
|
||||||
|
if (author == default)
|
||||||
|
{
|
||||||
|
Arrival.Drop(note == default ? "fetch-failed" : "author-unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (note == default || note.AttributedTo != author.ActorURI)
|
if (note == default || note.AttributedTo != author.ActorURI)
|
||||||
{
|
{
|
||||||
Arrival.Drop(note == default ? "unparseable" : "misattributed");
|
Arrival.Drop(note == default ? "unparseable" : "misattributed");
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
|
|
||||||
await RemoteDeletes.Tombstone(objectUri, token);
|
await RemoteDeletes.Tombstone(objectUri, token);
|
||||||
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
var post = await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI).ExecuteFirstAsync(token);
|
||||||
|
// deleted by another account of its author's server (Mobilizon's organiser deletes the group's event): once
|
||||||
|
// that server says it is gone
|
||||||
|
if (post == default && await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteFirstAsync(token) is { } held
|
||||||
|
&& held.ActorURI != actor.ActorURI && Origin.Same(held.ActorURI, actor.ActorURI) && await _remoteActors.IsGone(objectUri, token))
|
||||||
|
post = held;
|
||||||
if (post == default)
|
if (post == default)
|
||||||
{
|
{
|
||||||
Arrival.Accept("tombstone-only");
|
Arrival.Accept("tombstone-only");
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ namespace PrivaPub.Federation.Inbox.Handlers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var note = NoteParser.Parse(inner);
|
var note = NoteParser.Parse(inner);
|
||||||
|
// edited by another account of the author's own server (Mobilizon's organiser edits the group's event): applied
|
||||||
|
// as that server has it now
|
||||||
|
if (note != default && note.AttributedTo != actor.ActorURI && Origin.Same(note.AttributedTo, actor.ActorURI))
|
||||||
|
{
|
||||||
|
using var fetched = await _remoteActors.FetchObject(note.Id, token);
|
||||||
|
var current = fetched == default ? default : NoteParser.Parse(JsonNode.Parse(fetched.Root.GetRawText()));
|
||||||
|
if (current == default || current.Id != note.Id || current.AttributedTo != note.AttributedTo)
|
||||||
|
{
|
||||||
|
Arrival.Drop("fetch-failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
note = current;
|
||||||
|
actor = await _remoteActors.GetActor(note.AttributedTo, refresh: false, token);
|
||||||
|
if (actor == default)
|
||||||
|
{
|
||||||
|
Arrival.Drop("author-unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (note == default || note.AttributedTo != actor.ActorURI)
|
if (note == default || note.AttributedTo != actor.ActorURI)
|
||||||
{
|
{
|
||||||
Arrival.Drop(note == default ? "unparseable" : "misattributed");
|
Arrival.Drop(note == default ? "unparseable" : "misattributed");
|
||||||
|
|||||||
@@ -235,8 +235,10 @@ namespace PrivaPub.Federation.Inbox
|
|||||||
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", Reason: "undo-foreign");
|
return new(StatusCodes.Status400BadRequest, "an actor can only undo its own activities", Reason: "undo-foreign");
|
||||||
|
// (attributed to another account of the actor's own server, as Mobilizon's organiser creates a group's
|
||||||
|
// event: the handlers read it from that server, Origin.Same)
|
||||||
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 && !Origin.Same(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", Reason: "misattributed");
|
return new(StatusCodes.Status400BadRequest, "the object is not attributed to the actor", Reason: "misattributed");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user