Skip to content

Commit 14a2f8c

Browse files
committed
Fix critical authentication bypass vulnerability in inbox handler
This fixes a severe security vulnerability where activities were processed before verifying that the HTTP signature key belonged to the claimed actor, allowing attackers to impersonate any ActivityPub user. The fix moves the authentication check (doesActorOwnKey) to occur before calling routeActivity(), ensuring that malicious activities with mismatched signatures are rejected before any processing occurs. A comprehensive test case has been added to verify the fix and prevent regression of this critical security issue. GHSA-6jcc-xgcr-q3h4
1 parent 78a10fc commit 14a2f8c

File tree

3 files changed

+104
-14
lines changed

3 files changed

+104
-14
lines changed

CHANGES.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Version 1.3.20
88

99
To be released.
1010

11+
- Fixed a critical authentication bypass vulnerability in the inbox handler
12+
that allowed unauthenticated attackers to impersonate any ActivityPub actor.
13+
The vulnerability occurred because activities were processed before
14+
verifying that the HTTP Signatures key belonged to the claimed actor.
15+
Now authentication verification is performed before activity processing to
16+
prevent actor impersonation attacks. [[CVE-2025-54888]]
17+
18+
[CVE-2025-54888]: https://github.com/fedify-dev/fedify/security/advisories/GHSA-6jcc-xgcr-q3h4
19+
1120

1221
Version 1.3.19
1322
--------------

src/federation/handler.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
respondWithObject,
3636
respondWithObjectIfAcceptable,
3737
} from "./handler.ts";
38+
import { InboxListenerSet } from "./inbox.ts";
3839
import { MemoryKvStore } from "./kv.ts";
3940

4041
test("acceptsJsonLd()", () => {
@@ -1279,6 +1280,86 @@ test("respondWithObject()", async () => {
12791280
});
12801281
});
12811282

1283+
test("handleInbox() - authentication bypass vulnerability", async () => {
1284+
// This test reproduces the authentication bypass vulnerability where
1285+
// activities are processed before verifying the signing key belongs
1286+
// to the claimed actor
1287+
1288+
let processedActivity: Create | undefined;
1289+
const inboxListeners = new InboxListenerSet<void>();
1290+
inboxListeners.add(Create, (_ctx, activity) => {
1291+
// Track that the malicious activity was processed
1292+
processedActivity = activity;
1293+
});
1294+
1295+
// Create malicious activity claiming to be from victim actor
1296+
const maliciousActivity = new Create({
1297+
id: new URL("https://attacker.example.com/activities/malicious"),
1298+
actor: new URL("https://victim.example.com/users/alice"), // Impersonating victim
1299+
object: new Note({
1300+
id: new URL("https://attacker.example.com/notes/forged"),
1301+
attribution: new URL("https://victim.example.com/users/alice"),
1302+
content: "This is a forged message from the victim!",
1303+
}),
1304+
});
1305+
1306+
// Sign request with attacker's key (not victim's key)
1307+
const maliciousRequest = await signRequest(
1308+
new Request("https://example.com/", {
1309+
method: "POST",
1310+
body: JSON.stringify(await maliciousActivity.toJsonLd()),
1311+
}),
1312+
rsaPrivateKey3, // Attacker's private key
1313+
rsaPublicKey3.id!, // Attacker's public key ID
1314+
);
1315+
1316+
const maliciousContext = createRequestContext({
1317+
request: maliciousRequest,
1318+
url: new URL(maliciousRequest.url),
1319+
data: undefined,
1320+
documentLoader: mockDocumentLoader,
1321+
});
1322+
1323+
const actorDispatcher: ActorDispatcher<void> = (_ctx, identifier) => {
1324+
if (identifier !== "someone") return null;
1325+
return new Person({ name: "Someone" });
1326+
};
1327+
1328+
const response = await handleInbox(maliciousRequest, {
1329+
recipient: "someone",
1330+
context: maliciousContext,
1331+
inboxContextFactory(_activity) {
1332+
return createInboxContext({ ...maliciousContext, recipient: "someone" });
1333+
},
1334+
kv: new MemoryKvStore(),
1335+
kvPrefixes: {
1336+
activityIdempotence: ["_fedify", "activityIdempotence"],
1337+
publicKey: ["_fedify", "publicKey"],
1338+
},
1339+
actorDispatcher,
1340+
inboxListeners,
1341+
onNotFound: () => new Response("Not found", { status: 404 }),
1342+
signatureTimeWindow: { minutes: 5 },
1343+
skipSignatureVerification: false,
1344+
});
1345+
1346+
// The vulnerability: Even though the response is 401 (unauthorized),
1347+
// the malicious activity was already processed by routeActivity()
1348+
assertEquals(response.status, 401);
1349+
assertEquals(await response.text(), "The signer and the actor do not match.");
1350+
1351+
assertEquals(
1352+
processedActivity,
1353+
undefined,
1354+
`SECURITY VULNERABILITY: Malicious activity with mismatched signature was processed! ` +
1355+
`Activity ID: ${processedActivity?.id?.href}, ` +
1356+
`Claimed actor: ${processedActivity?.actorId?.href}`,
1357+
);
1358+
1359+
// If we reach here, the vulnerability is fixed - activities with mismatched
1360+
// signatures are properly rejected before processing
1361+
});
1362+
12821363
test("respondWithObjectIfAcceptable", async () => {
12831364
let request = new Request("https://example.com/", {
12841365
headers: { Accept: "application/activity+json" },

src/federation/handler.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -649,20 +649,6 @@ async function handleInboxInternal<TContextData>(
649649
span.setAttribute("activitypub.activity.id", activity.id.href);
650650
}
651651
span.setAttribute("activitypub.activity.type", getTypeId(activity).href);
652-
const routeResult = await routeActivity({
653-
context: ctx,
654-
json,
655-
activity,
656-
recipient,
657-
inboxListeners,
658-
inboxContextFactory,
659-
inboxErrorHandler,
660-
kv,
661-
kvPrefixes,
662-
queue,
663-
span,
664-
tracerProvider,
665-
});
666652
if (
667653
httpSigKey != null && !await doesActorOwnKey(activity, httpSigKey, ctx)
668654
) {
@@ -685,6 +671,20 @@ async function handleInboxInternal<TContextData>(
685671
headers: { "Content-Type": "text/plain; charset=utf-8" },
686672
});
687673
}
674+
const routeResult = await routeActivity({
675+
context: ctx,
676+
json,
677+
activity,
678+
recipient,
679+
inboxListeners,
680+
inboxContextFactory,
681+
inboxErrorHandler,
682+
kv,
683+
kvPrefixes,
684+
queue,
685+
span,
686+
tracerProvider,
687+
});
688688
if (routeResult === "alreadyProcessed") {
689689
return new Response(
690690
`Activity <${activity.id}> has already been processed.`,

0 commit comments

Comments
 (0)