diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index 5d925999..9f8acb0e 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -3,7 +3,7 @@ import { eq, and, or, desc, lt, inArray, isNull, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; -import { isDmMember } from '../utils/permissions.js'; +import { isDmMember, isDeadOneOnOne } from '../utils/permissions.js'; import { connectionManager } from '../ws/handler.js'; import { MAX_MESSAGE_LENGTH, @@ -2456,6 +2456,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } + if (isDeadOneOnOne(id, request.userId)) { + return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 }); + } + const hasContent = content && typeof content === 'string' && content.trim().length > 0; const hasAttachments = attachmentIds && attachmentIds.length > 0; @@ -2548,6 +2552,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You can only edit your own messages', statusCode: 403 }); } + if (isDeadOneOnOne(msg.dmChannelId, request.userId)) { + return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 }); + } + const now = Date.now(); db.update(schema.dmMessages) .set({ content: content.trim(), editedAt: now }) @@ -2600,6 +2608,10 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 }); } + if (isDeadOneOnOne(msg.dmChannelId, request.userId)) { + return reply.code(403).send({ error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 }); + } + // Collect attachment filenames before deleting const attachmentRows = db.select({ filename: schema.attachments.filename }) .from(schema.attachments) diff --git a/packages/server/src/utils/permissions.ts b/packages/server/src/utils/permissions.ts index 32495387..c2de7850 100644 --- a/packages/server/src/utils/permissions.ts +++ b/packages/server/src/utils/permissions.ts @@ -1,4 +1,4 @@ -import { eq, and } from 'drizzle-orm'; +import { eq, and, sql } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { PermissionBits, @@ -261,6 +261,28 @@ export function isDmMember(dmChannelId: string, userId: string): boolean { return member !== undefined; } +/** + * True when a DM is a 1-on-1 (ownerId NULL) whose only other participant(s) + * are tombstoned (isDeleted=1). Used to make a Deleted-User thread read-only: + * no message create/edit/delete, so we never enqueue doomed/mis-directed relays. + */ +export function isDeadOneOnOne(dmChannelId: string, requesterId: string): boolean { + const db = getDb(); + const channel = db.select({ ownerId: schema.dmChannels.ownerId }) + .from(schema.dmChannels).where(eq(schema.dmChannels.id, dmChannelId)).get(); + if (!channel || channel.ownerId !== null) return false; // groups are never a dead 1-on-1 + const others = db.select({ isDeleted: schema.users.isDeleted }) + .from(schema.dmMembers) + .innerJoin(schema.users, eq(schema.dmMembers.userId, schema.users.id)) + .where(and( + eq(schema.dmMembers.dmChannelId, dmChannelId), + sql`${schema.dmMembers.userId} != ${requesterId}`, + )) + .all(); + if (others.length === 0) return false; + return others.every(o => o.isDeleted === 1); +} + export function isBanned(spaceId: string, userId: string): boolean { const db = getDb(); const ban = db.select().from(schema.bans) diff --git a/packages/server/test/federation-identity-deletion.test.ts b/packages/server/test/federation-identity-deletion.test.ts index c184f3d0..957bf6c4 100644 --- a/packages/server/test/federation-identity-deletion.test.ts +++ b/packages/server/test/federation-identity-deletion.test.ts @@ -402,6 +402,55 @@ describe('Federation identity deletion — server suite', () => { remote.close(); }); + it('#19 read-only: mutations on a Deleted-User 1-on-1 are rejected 403 recipient_deleted', async () => { + const fx = await setupFullDeletionFixture('t19'); + + // Survivor authors a message BEFORE the deletion so there is a message THEY own + // to edit/delete — this ensures the read-only guard (not the ownership 403) is + // what's exercised on the PATCH/DELETE paths, which resolve via msg.dmChannelId. + const pre = await fetch(`${harness.remote.origin}/api/dm/${fx.dmChannelId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` }, + body: JSON.stringify({ content: 'before deletion' }), + }); + expect(pre.status).toBe(201); + const { id: messageId } = await pre.json() as { id: string }; + + // Tombstone the remote (federated) user via soft delete + const del = await fetch(`${harness.home.origin}/api/users/@me/federation-identity/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.homeUser.token}` }, + body: JSON.stringify({ origins: [harness.remote.origin], mode: 'soft' }), + }); + expect(del.status).toBe(200); + + // Survivor (observerOnRemote) POST -> 403 recipient_deleted + const post = await fetch(`${harness.remote.origin}/api/dm/${fx.dmChannelId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` }, + body: JSON.stringify({ content: 'still there?' }), + }); + expect(post.status).toBe(403); + expect((await post.json()).code).toBe('recipient_deleted'); + + // PATCH the survivor's own message (resolves channel via msg.dmChannelId) -> 403 + const patch = await fetch(`${harness.remote.origin}/api/dm/messages/${messageId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${fx.observerOnRemote.token}` }, + body: JSON.stringify({ content: 'edit' }), + }); + expect(patch.status).toBe(403); + expect((await patch.json()).code).toBe('recipient_deleted'); + + // DELETE the survivor's own message (resolves channel via msg.dmChannelId) -> 403 + const remove = await fetch(`${harness.remote.origin}/api/dm/messages/${messageId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${fx.observerOnRemote.token}` }, + }); + expect(remove.status).toBe(403); + expect((await remove.json()).code).toBe('recipient_deleted'); + }); + it('#7 owned-spaces 409: ownership prevents deletion, registry preserved', async () => { const { createFederatedUser } = await import('./helpers/testUsers.js'); const { openInspector } = await import('./helpers/dbInspect.js');