Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
- Fix: リスト編集画面におけるユーザー追加時のユーザー選択ダイアログにおいて、自身のアカウントが検索結果の一覧に表示されない問題を修正

### Server
-

- Fix: AP Inbox 処理において `isSuspended` チェックと対称に `isDeleted` チェックが行われていなかった問題を修正


## 2026.5.1
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/core/activitypub/ApInboxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export class ApInboxService {

@bindThis
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
if (actor.isSuspended) return;
if (actor.isSuspended || actor.isDeleted) return;

if (isCreate(activity)) {
return await this.create(actor, activity, resolver);
Expand Down Expand Up @@ -302,7 +302,7 @@ export class ApInboxService {

@bindThis
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
if (actor.isSuspended) {
if (actor.isSuspended || actor.isDeleted) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,9 @@ export class ApImageService {
*/
@bindThis
public async createImage(actor: MiRemoteUser, value: string | IObject): Promise<MiDriveFile | null> {
// 投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new Error('actor has been suspended');
}
// 投稿者が凍結または論理削除されていたらスキップ
if (actor.isSuspended) throw new Error('actor has been suspended');
if (actor.isDeleted) throw new Error('actor has been deleted');

const image = await (await this.apResolverService.createResolver()).resolve(value);

Expand Down
18 changes: 9 additions & 9 deletions packages/backend/src/core/activitypub/models/ApNoteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,11 +170,12 @@ export class ApNoteService {

const uri = getOneApId(note.attributedTo);

// ローカルで投稿者を検索し、もし凍結されていたらスキップ
// ローカルで投稿者を検索し、もし凍結または論理削除されていたらスキップ
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
if (actor && actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
if (actor) {
if (actor.isSuspended) throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
if (actor.isDeleted) throw new IdentifiableError('bdf46093-6804-5632-2e4f-55d23d0bf9c2', 'actor has been deleted');
}

const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver);
Expand Down Expand Up @@ -208,10 +209,9 @@ export class ApNoteService {
// eslint-disable-next-line no-param-reassign
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;

// 解決した投稿者が凍結されていたらスキップ
if (actor.isSuspended) {
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
}
// 解決した投稿者が凍結または論理削除されていたらスキップ
if (actor.isSuspended) throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
if (actor.isDeleted) throw new IdentifiableError('bdf46093-6804-5632-2e4f-55d23d0bf9c2', 'actor has been deleted');

const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
let visibility = noteAudience.visibility;
Expand Down Expand Up @@ -412,7 +412,7 @@ export class ApNoteService {
publicUrl: tag.icon.url,
updatedAt: new Date(),
// _misskey_license が存在しなければ `null`
license: (tag._misskey_license?.freeText ?? null)
license: (tag._misskey_license?.freeText ?? null),
});

const emoji = await this.emojisRepository.findOneBy({ host, name });
Expand All @@ -435,7 +435,7 @@ export class ApNoteService {
updatedAt: new Date(),
aliases: [],
// _misskey_license が存在しなければ `null`
license: (tag._misskey_license?.freeText ?? null)
license: (tag._misskey_license?.freeText ?? null),
});
}));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
return 'blocked notes with prohibited words';
case '85ab9bd7-3a41-4530-959d-f07073900109':
return 'actor has been suspended';
case 'bdf46093-6804-5632-2e4f-55d23d0bf9c2':
return 'actor has been deleted';
case 'd450b8a9-48e4-4dab-ae36-f4db763fda7c': // invalid Note
return e.message;
case '9f466dab-c856-48cd-9e65-ff90ff750580':
Expand Down
71 changes: 70 additions & 1 deletion packages/backend/test-federation/test/note.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, test, beforeAll, afterAll } from 'vitest';
import assert, { rejects, strictEqual } from 'node:assert';
import * as Misskey from 'misskey-js';
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';

describe('Note', () => {
let alice: LoginUser, bob: LoginUser;
Expand Down Expand Up @@ -328,6 +328,75 @@ describe('Note', () => {
});
});

describe('Deleted user', () => {
/**
* Issueの再現シナリオ:
* A側でリモートBのユーザー(bob)を論理削除した後に、
* B側の別ユーザー(carol)がbobのノートをAnnounce/Replyで
* A側inboxに送っても、bobのノートがA側DBに作成されないことを確認する。
* @see https://github.com/misskey-dev/misskey/issues/17393
*/
describe('Announce/Reply from a healthy user targeting a locally-deleted user\'s note is rejected', () => {
let bob: LoginUser, carol: LoginUser;
let bobInA: Misskey.entities.UserDetailedNotMe;

beforeAll(async () => {
// bob: B側で健在のユーザー(A側で論理削除対象)
// carol: B側で健在の別ユーザー(Announceを行う側)
[bob, carol] = await Promise.all([
createAccount('b.test'),
createAccount('b.test'),
]);

// A側にbobを認識させる
bobInA = await resolveRemoteUser('b.test', bob.id, alice);

// A側adminがbobを論理削除(soft delete)
const aAdmin = await fetchAdmin('a.test');
await aAdmin.client.request('admin/delete-account', { userId: bobInA.id });
await sleep();
});

test('Announce of deleted user\'s note is not created in A', async () => {
// B側でbobが新規ノートを投稿(A側にはまだキャッシュされていない)
const bobNote = (await bob.client.request('notes/create', { text: 'I am Bob and this should not appear in A' })).createdNote;

// carolがbobのノートをrenote(→ A側inboxにAnnounce Activityが届く)
const carolRenote = (await carol.client.request('notes/create', { renoteId: bobNote.id })).createdNote;
await sleep();

// A側でcarolのrenote(およびそのrenote元 = bobのノート)が作成されていないことを確認
await rejects(
async () => await alice.client.request('ap/show', { uri: `https://b.test/notes/${carolRenote.id}` }),
(err: any) => {
// bobのノートがisDeletedで弾かれるため、carolのrenoteも作成できずエラーになる
strictEqual(err.code, 'REQUEST_FAILED');
return true;
},
);
});

test('Reply to deleted user\'s note is not created in A', async () => {
// B側でbobが新規ノートを投稿(A側にはまだキャッシュされていない)
const bobNote = (await bob.client.request('notes/create', { text: 'reply target from deleted user' })).createdNote;

// carolがbobのノートにreply(→ A側inboxにCreate Activityが届く)
const carolReply = (await carol.client.request('notes/create', { text: 'reply to deleted', replyId: bobNote.id })).createdNote;
await sleep();

// A側でcarolのreply(およびそのreply先 = bobのノート)が作成されていないことを確認
await rejects(
async () => await alice.client.request('ap/show', { uri: `https://b.test/notes/${carolReply.id}` }),
(err: any) => {
// bobのノートがisDeletedで弾かれるため、carolのreplyも作成できずエラーになる
strictEqual(err.code, 'REQUEST_FAILED');
return true;
},
);
});
});
});

describe('Poll', () => {
describe('Any remote user\'s vote is delivered to the author', () => {
let carol: LoginUser;
Expand Down
Loading