fix: prevent resolveOrCreateReplicatedUser from recreating deleted user stubs

When a federated user's identity has been tombstoned (isDeleted=1),
findFederatedUser filters them out, causing resolveOrCreateReplicatedUser
to silently create a new stub — a "zombie" resurrection. This guard checks
for a deleted row before creating a stub and returns null instead.

All 11 call sites across federation.ts and dm.ts have been updated with
appropriate null guards: federation relay handlers reject or skip the event
(participant_not_found / accepted no-op), while dm.ts routes convert null
to undefined so the existing 404 path handles it.
This commit is contained in:
Jannis Braun
2026-04-03 02:32:36 +02:00
parent bb4b031423
commit 4da373c970
2 changed files with 64 additions and 14 deletions
+3 -3
View File
@@ -384,7 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (homeUserId && homeInstance) { if (homeUserId && homeInstance) {
// Federated identity: resolve or create a replicated user stub // Federated identity: resolve or create a replicated user stub
targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db); targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined;
} else if (userId && typeof userId === 'string') { } else if (userId && typeof userId === 'string') {
// Local ID: direct lookup (existing behavior) // Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get(); targetUser = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
@@ -570,7 +570,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (identity.homeUserId && identity.homeInstance) { if (identity.homeUserId && identity.homeInstance) {
// Federated user — resolve via homeUserId, creating a replicated stub if needed // Federated user — resolve via homeUserId, creating a replicated stub if needed
localUser = resolveOrCreateReplicatedUser(identity.homeUserId, identity.homeInstance, db); localUser = resolveOrCreateReplicatedUser(identity.homeUserId, identity.homeInstance, db) ?? undefined;
} else { } else {
// Local user — direct ID lookup // Local user — direct ID lookup
localUser = db.select().from(schema.users).where( localUser = db.select().from(schema.users).where(
@@ -865,7 +865,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
if (homeUserId && homeInstance) { if (homeUserId && homeInstance) {
// Federated identity: resolve or create a replicated user stub // Federated identity: resolve or create a replicated user stub
targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db); targetUser = resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) ?? undefined;
} else if (targetUserIdRaw && typeof targetUserIdRaw === 'string') { } else if (targetUserIdRaw && typeof targetUserIdRaw === 'string') {
// Local ID: direct lookup (existing behavior) // Local ID: direct lookup (existing behavior)
targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get(); targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserIdRaw)).get();
+61 -11
View File
@@ -1397,10 +1397,23 @@ export function resolveOrCreateReplicatedUser(
homeInstance: string, homeInstance: string,
db: ReturnType<typeof getDb>, db: ReturnType<typeof getDb>,
hints?: { username?: string | null }, hints?: { username?: string | null },
): typeof schema.users.$inferSelect { ): typeof schema.users.$inferSelect | null {
const existing = findFederatedUser(homeUserId, homeInstance, db, hints); const existing = findFederatedUser(homeUserId, homeInstance, db, hints);
if (existing) return backfillHomeUserId(existing, homeUserId, db); if (existing) return backfillHomeUserId(existing, homeUserId, db);
// Check if this identity was previously deleted — don't resurrect a tombstoned
// user by creating a new stub. The isDeleted=0 filter in findFederatedUser
// already hides the deleted row, so we must query without that filter here.
const deletedMatch = db
.select({ id: schema.users.id, isDeleted: schema.users.isDeleted })
.from(schema.users)
.where(eq(schema.users.homeUserId, homeUserId))
.get();
if (deletedMatch?.isDeleted) {
console.log(`[federation] Skipping stub creation for deleted identity homeUserId=${homeUserId} (tombstoned)`);
return null;
}
// Normalize homeInstance to bare domain for consistent storage // Normalize homeInstance to bare domain for consistent storage
const domain = extractDomain(homeInstance); const domain = extractDomain(homeInstance);
@@ -1612,6 +1625,8 @@ function processCreateEvent(
for (const p of event.participants) { for (const p of event.participants) {
let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username }); let localUser = resolveOrCreateReplicatedUser(p.homeUserId, p.homeInstance, db, { username: p.profile?.username });
// Skip deleted identities — don't include tombstoned users in the DM
if (!localUser) continue;
// Hydrate with profile data from the relay event (displayName, avatar, etc.) // Hydrate with profile data from the relay event (displayName, avatar, etc.)
if (p.profile) { if (p.profile) {
localUser = hydrateReplicatedUserProfile(localUser, p.profile, db); localUser = hydrateReplicatedUserProfile(localUser, p.profile, db);
@@ -2175,7 +2190,7 @@ function processMemberAddEvent(
let ownerId: string | null = null; let ownerId: string | null = null;
if (event.group.owner) { if (event.group.owner) {
const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username }); const ownerLocal = resolveOrCreateReplicatedUser(event.group.owner.homeUserId, event.group.owner.homeInstance, db, { username: event.group.owner.profile?.username });
ownerId = ownerLocal.id; ownerId = ownerLocal?.id ?? null;
} }
db.insert(schema.dmChannels) db.insert(schema.dmChannels)
@@ -2193,6 +2208,8 @@ function processMemberAddEvent(
// participants from remote instances that haven't been seen before. // participants from remote instances that haven't been seen before.
for (const member of event.group.members) { for (const member of event.group.members) {
const localUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username }); const localUser = resolveOrCreateReplicatedUser(member.homeUserId, member.homeInstance, db, { username: member.profile?.username });
// Skip deleted identities — tombstoned users can't be added to a DM
if (!localUser) continue;
const existing = db.select().from(schema.dmMembers) const existing = db.select().from(schema.dmMembers)
.where(and( .where(and(
eq(schema.dmMembers.dmChannelId, channelId), eq(schema.dmMembers.dmChannelId, channelId),
@@ -2281,6 +2298,11 @@ function processMemberAddEvent(
db, db,
{ username: event.membership.user.profile?.username }, { username: event.membership.user.profile?.username },
); );
if (!localUser) {
// The user's identity has been deleted — don't add a tombstoned user to the DM
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
return;
}
// Enforce max 10 members // Enforce max 10 members
const memberCount = db.select() const memberCount = db.select()
@@ -2516,15 +2538,19 @@ function processOwnershipTransferEvent(
return; return;
} }
// Resolve new owner to local user — use resolveOrCreateReplicatedUser to // Resolve new owner to local user. If the new owner's identity has been
// guarantee we always get a valid user ID. Never fall back to null, as that // deleted, we cannot complete the transfer — reject so the event can be
// would convert the group DM into a 1-on-1 and destroy its type identity. // retried or dropped by the sender.
const newOwnerLocal = resolveOrCreateReplicatedUser( const newOwnerLocal = resolveOrCreateReplicatedUser(
event.ownership.newOwner.homeUserId, event.ownership.newOwner.homeUserId,
event.ownership.newOwner.homeInstance, event.ownership.newOwner.homeInstance,
db, db,
{ username: event.ownership.newOwner.profile?.username }, { username: event.ownership.newOwner.profile?.username },
); );
if (!newOwnerLocal) {
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
return;
}
db.update(schema.dmChannels) db.update(schema.dmChannels)
.set({ .set({
@@ -2654,8 +2680,13 @@ function processFriendRequestCreateEvent(
} }
// Resolve the sender (create stub if needed — they're on a remote instance) // Resolve the sender (create stub if needed — they're on a remote instance)
let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username });
fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); if (!fromUserResolved) {
// Sender's identity has been deleted — silently accept to drop the event
accepted.push(event.messageId);
return;
}
let fromUser = hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
// Resolve the recipient — must be a local user on this instance // Resolve the recipient — must be a local user on this instance
const toUser = resolveLocalUser(to.homeUserId, db); const toUser = resolveLocalUser(to.homeUserId, db);
@@ -2759,6 +2790,11 @@ function processFriendRequestUpdateEvent(
// Resolve the recipient (create stub if needed — they're on the remote instance) // Resolve the recipient (create stub if needed — they're on the remote instance)
const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); const toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username });
if (!toUser) {
// Recipient's identity has been deleted — accept idempotently to drop the event
accepted.push(event.messageId);
return;
}
// Find the pending request // Find the pending request
const pendingRequest = db const pendingRequest = db
@@ -2893,10 +2929,19 @@ function processFriendAddEvent(
} }
// Resolve both users (create stubs if needed) and hydrate with profile data // Resolve both users (create stubs if needed) and hydrate with profile data
let fromUser = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username }); const fromUserResolved = resolveOrCreateReplicatedUser(from.homeUserId, from.homeInstance, db, { username: event.friendship.fromProfile?.username });
fromUser = hydrateReplicatedUserProfile(fromUser, event.friendship.fromProfile, db); if (!fromUserResolved) {
let toUser = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username }); // One party's identity is deleted — accept idempotently to drop the event
toUser = hydrateReplicatedUserProfile(toUser, event.friendship.toProfile, db); accepted.push(event.messageId);
return;
}
let fromUser = hydrateReplicatedUserProfile(fromUserResolved, event.friendship.fromProfile, db);
const toUserResolved = resolveOrCreateReplicatedUser(to.homeUserId, to.homeInstance, db, { username: event.friendship.toProfile?.username });
if (!toUserResolved) {
accepted.push(event.messageId);
return;
}
let toUser = hydrateReplicatedUserProfile(toUserResolved, event.friendship.toProfile, db);
// Idempotency: if friendship already exists, accept as no-op // Idempotency: if friendship already exists, accept as no-op
const existingFriend = db const existingFriend = db
@@ -3171,6 +3216,11 @@ function processDmCallStartEvent(
db, db,
{ username: event.call.caller.displayName }, { username: event.call.caller.displayName },
); );
if (!callerStub) {
// Caller's identity has been deleted — can't initiate a call as a tombstoned user
rejected.push({ messageId: event.messageId, reason: 'participant_not_found' });
return;
}
// Create federated call entry in registry // Create federated call entry in registry
const entry: FederatedCallEntry = { const entry: FederatedCallEntry = {