Skip to content

Commit f49f9be

Browse files
committed
Handle unverified deletes from gone actors
Register an unverified activity hook for inbox deliveries and acknowledge Delete activities whose signing key fetch fails with 410 Gone. This prevents repeated delivery attempts from remote servers after an actor has been deleted while keeping the default 401 behavior for other unverified requests.
1 parent b06dea2 commit f49f9be

File tree

3 files changed

+118
-2
lines changed

3 files changed

+118
-2
lines changed

CHANGES.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ To be released.
6767
associated follows, mentions, likes, etc. via cascade) since the
6868
actor is explicitly marked as permanently gone.
6969

70+
- Improved inbox handling for deleted remote actors using Fedify 2.1.0's
71+
unverified activity hooks. Hollo now acknowledges unverifiable `Delete`
72+
activities with `202 Accepted` when the signing key fetch fails with
73+
`410 Gone`, preventing repeated delivery retries for actors that have
74+
already been permanently deleted.
75+
7076
- Added `FEDIFY_DEBUG` environment variable to enable the [Fedify debugger],
7177
an embedded real-time dashboard for inspecting ActivityPub traces and
7278
activities. When enabled, the debug dashboard is accessible at
@@ -79,7 +85,7 @@ To be released.
7985
emoji reaction/unreaction, follow request lifecycle messages,
8086
block/unblock, and post updates triggered by replies and poll votes.
8187

82-
- Upgraded Fedify to 2.0.7.
88+
- Upgraded Fedify to 2.1.0.
8389

8490
[#348]: https://github.com/fedify-dev/hollo/issues/348
8591
[#350]: https://github.com/fedify-dev/hollo/issues/350

src/federation/index.test.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import type { UnverifiedActivityReason } from "@fedify/fedify";
2+
import { Delete, Follow } from "@fedify/vocab";
13
import { eq } from "drizzle-orm";
24
import { beforeEach, describe, expect, it } from "vitest";
35
import { cleanDatabase } from "../../tests/helpers";
46
import { createAccount } from "../../tests/helpers/oauth";
57
import db from "../db";
68
import * as Schema from "../schema";
79
import type { Uuid } from "../uuid";
8-
import { onOutboxPermanentFailure } from "./index";
10+
import { onOutboxPermanentFailure, onUnverifiedActivity } from "./index";
911

1012
async function createRemoteAccount(
1113
username: string,
@@ -59,6 +61,17 @@ async function createFollow(
5961
});
6062
}
6163

64+
function createKeyFetchErrorReason(status: number): UnverifiedActivityReason {
65+
return {
66+
type: "keyFetchError",
67+
keyId: new URL("https://remote.test/@alice#main-key"),
68+
result: {
69+
status,
70+
response: new Response(null, { status }),
71+
},
72+
};
73+
}
74+
6275
describe("onOutboxPermanentFailure", () => {
6376
beforeEach(async () => {
6477
await cleanDatabase();
@@ -293,3 +306,83 @@ describe("onOutboxPermanentFailure", () => {
293306
});
294307
});
295308
});
309+
310+
describe("onUnverifiedActivity", () => {
311+
it("should acknowledge Delete activities whose actor key returns 410 Gone", async () => {
312+
expect.assertions(1);
313+
314+
const response = await onUnverifiedActivity(
315+
null as never,
316+
new Delete({
317+
actor: new URL("https://remote.test/@alice"),
318+
object: new URL("https://remote.test/@alice"),
319+
}),
320+
createKeyFetchErrorReason(410),
321+
);
322+
323+
expect(response?.status).toBe(202);
324+
});
325+
326+
it("should ignore Delete activities whose actor key returns 404", async () => {
327+
expect.assertions(1);
328+
329+
const response = await onUnverifiedActivity(
330+
null as never,
331+
new Delete({
332+
actor: new URL("https://remote.test/@alice"),
333+
object: new URL("https://remote.test/@alice"),
334+
}),
335+
createKeyFetchErrorReason(404),
336+
);
337+
338+
expect(response).toBeUndefined();
339+
});
340+
341+
it("should ignore non-Delete activities even if the key fetch returns 410", async () => {
342+
expect.assertions(1);
343+
344+
const response = await onUnverifiedActivity(
345+
null as never,
346+
new Follow({
347+
actor: new URL("https://remote.test/@alice"),
348+
object: new URL("https://hollo.test/@owner"),
349+
}),
350+
createKeyFetchErrorReason(410),
351+
);
352+
353+
expect(response).toBeUndefined();
354+
});
355+
356+
it("should ignore invalid signatures", async () => {
357+
expect.assertions(1);
358+
359+
const response = await onUnverifiedActivity(
360+
null as never,
361+
new Delete({
362+
actor: new URL("https://remote.test/@alice"),
363+
object: new URL("https://remote.test/@alice"),
364+
}),
365+
{
366+
type: "invalidSignature",
367+
keyId: new URL("https://remote.test/@alice#main-key"),
368+
},
369+
);
370+
371+
expect(response).toBeUndefined();
372+
});
373+
374+
it("should ignore unsigned activities", async () => {
375+
expect.assertions(1);
376+
377+
const response = await onUnverifiedActivity(
378+
null as never,
379+
new Delete({
380+
actor: new URL("https://remote.test/@alice"),
381+
object: new URL("https://remote.test/@alice"),
382+
}),
383+
{ type: "noSignature" },
384+
);
385+
386+
expect(response).toBeUndefined();
387+
});
388+
});

src/federation/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { UnverifiedActivityHandler } from "@fedify/fedify";
12
import {
23
Accept,
34
Activity,
@@ -56,8 +57,24 @@ import { isPost } from "./post";
5657

5758
const inboxLogger = getLogger(["hollo", "federation", "inbox"]);
5859

60+
export const onUnverifiedActivity: UnverifiedActivityHandler<void> = (
61+
_ctx,
62+
activity,
63+
reason,
64+
) => {
65+
if (
66+
activity instanceof Delete &&
67+
reason.type === "keyFetchError" &&
68+
"status" in reason.result &&
69+
reason.result.status === 410
70+
) {
71+
return new Response(null, { status: 202 });
72+
}
73+
};
74+
5975
federation
6076
.setInboxListeners("/@{identifier}/inbox", "/inbox")
77+
.onUnverifiedActivity(onUnverifiedActivity)
6178
.setSharedKeyDispatcher(async (_) => {
6279
const anyOwner = await db.query.accountOwners.findFirst();
6380
return anyOwner == null ? null : { username: anyOwner.handle };

0 commit comments

Comments
 (0)