diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 4c13d7e156d4..69f5dfbd381c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -153,6 +153,7 @@ import { VmimiRelayTimelineService } from './VmimiRelayTimelineService.js'; import { QueueModule } from './QueueModule.js'; import { QueueService } from './QueueService.js'; import { LoggerService } from './LoggerService.js'; +import { SpamFilterService } from './SpamFilterService.js'; import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) @@ -454,6 +455,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + SpamFilterService, //#region 文字列ベースでのinjection用(循環参照対応のため) $VmimiRelayTimelineService, @@ -750,6 +752,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApPersonService, ApQuestionService, QueueService, + SpamFilterService, //#region 文字列ベースでのinjection用(循環参照対応のため) $VmimiRelayTimelineService, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 09db66b90c2b..1aca9ef52a92 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -57,6 +57,7 @@ import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { SpamFilterService } from '@/core/SpamFilterService.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -219,6 +220,7 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, + private spamFilterService: SpamFilterService, ) { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); } @@ -368,15 +370,15 @@ export class NoteCreateService implements OnApplicationShutdown { // if the host is media-silenced, custom emojis are not allowed if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; - const willCauseNotification = mentionedUsers.some(u => u.host === null) - || (data.visibility === 'specified' && data.visibleUsers?.some(u => u.host === null)) - || data.reply?.userHost === null || (this.isRenote(data) && this.isQuote(data) && data.renote.userHost === null) || false; - - if (process.env.MISSKEY_BLOCK_MENTIONS_FROM_UNFAMILIAR_REMOTE_USERS === 'true' && user.host !== null && willCauseNotification) { - const userEntity = await this.usersRepository.findOneBy({ id: user.id }); - if ((userEntity?.followersCount ?? 0) === 0) { - throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.'); - } + if (await this.spamFilterService.isSpam({ + mentionedUsers, + visibility: data.visibility, + visibleUsers: data.visibleUsers ?? [], + reply: data.reply ?? null, + quote: this.isRenote(data) && this.isQuote(data) ? data.renote : null, + user: user, + })) { + throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.'); } tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); diff --git a/packages/backend/src/core/SpamFilterService.ts b/packages/backend/src/core/SpamFilterService.ts new file mode 100644 index 000000000000..bbc5fe2342fa --- /dev/null +++ b/packages/backend/src/core/SpamFilterService.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: anatawa12 + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MiMeta, MiNote, MiUser, UsersRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; + +type MinimumUser = { + id: MiUser['id']; + host: MiUser['host']; + username: MiUser['username']; + uri: MiUser['uri']; +}; + +type AuthorUser = { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; +}; + +@Injectable() +export class SpamFilterService { + constructor( + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + ) { + } + + @bindThis + public async isSpam( + { + mentionedUsers, + visibility, + visibleUsers, + reply, + quote, + user, + }: { + mentionedUsers: readonly MinimumUser[], + visibility: MiNote['visibility'] | string, + visibleUsers: readonly MinimumUser[], + reply: MiNote | null, + quote: MiNote | null, + // null if new remote user + user: AuthorUser | null, + }, + ) { + // spam filter is enabled + if (process.env.MISSKEY_BLOCK_MENTIONS_FROM_UNFAMILIAR_REMOTE_USERS !== 'true') return false; + // do not check for local user + if (user != null && user.host === null) return false; + + // get list of users that will create notification + const targetUserIds: string[] = [ + ...mentionedUsers.filter(x => x.host == null).map(x => x.id), + ...(visibility === 'specified' ? visibleUsers.filter(x => x.host == null).map(x => x.id) : []), + ...(reply != null && reply.userHost == null ? [reply.userId] : []), + ...(quote != null && quote.userHost === null ? [quote.userId] : []), + ]; + + // if notification is created only for allowed users, it's not a spam or harmful + // if no notification is created, it's not a spam or harmful + const allowedIds = new Set([]); + if (targetUserIds.every(id => allowedIds.has(id))) return false; + + // if someone follows the user, it's not a spam + if (await this.hasFollower(user)) return false; + + // all conditions are met, it's a spam + return true; + } + + private async hasFollower(user: AuthorUser | null): Promise { + // the user is new user so no one follows the user + if (user == null) return false; + + // if the user looks like MiUser, check followersCount + if ('followersCount' in user && typeof user.followersCount === 'number') { + return user.followersCount > 0; + } + + const userEntity = await this.usersRepository.findOneBy({ id: user.id }); + // user not found + if (userEntity == null) return false; + + return userEntity.followersCount > 0; + } +} diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts index 5a5a76f7d65f..0ca7e42f2adb 100644 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -31,7 +31,7 @@ export class ApAudienceService { } @bindThis - public async parseAudience(actor: MiRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { + public async parseAudience(actor: MiRemoteUser | null, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise { const toGroups = this.groupingAudience(getApIds(to), actor); const ccGroups = this.groupingAudience(getApIds(cc), actor); @@ -74,7 +74,7 @@ export class ApAudienceService { } @bindThis - private groupingAudience(ids: string[], actor: MiRemoteUser): GroupedAudience { + private groupingAudience(ids: string[], actor: MiRemoteUser | null): GroupedAudience { const groups: GroupedAudience = { public: [], followers: [], @@ -106,7 +106,8 @@ export class ApAudienceService { } @bindThis - private isFollowers(id: string, actor: MiRemoteUser): boolean { + private isFollowers(id: string, actor: MiRemoteUser | null): boolean { + if (actor == null) return false; return id === (actor.followersUri ?? `${actor.uri}/followers`); } } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 2d333b3634d6..248ce7e0578a 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -23,6 +23,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { SpamFilterService } from '@/core/SpamFilterService.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; @@ -72,6 +73,7 @@ export class ApNoteService { private noteCreateService: NoteCreateService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, + private spamFilterService: SpamFilterService, ) { this.logger = this.apLoggerService.logger; } @@ -156,7 +158,7 @@ export class ApNoteService { const uri = getOneApId(note.attributedTo); // ローカルで投稿者を検索し、もし凍結されていたらスキップ - const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; + const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser | null; if (cachedActor && cachedActor.isSuspended) { throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); } @@ -189,6 +191,44 @@ export class ApNoteService { } //#endregion + // spam check + { + // fetch audience information. + // this logic may treat followers as direct audience, but it's not a problem for spam check. + const noteAudience = await this.apAudienceService.parseAudience(cachedActor, note.to, note.cc, resolver); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // Audience (to, cc) が指定されてなかった場合 + if (visibility === 'specified' && visibleUsers.length === 0) { + if (typeof value === 'string') { // 入力がstringならばresolverでGETが発生している + // こちらから匿名GET出来たものならばpublic + visibility = 'public'; + } + } + + // for spam check, we only want to know if the reply / quote target is local user or not. + // so we don't have to resolve the note, we just fetch from DB. + // reply + const reply: MiNote | null = note.inReplyTo ? await this.fetchNote(getApId(note.inReplyTo)) : null; + + // 引用 + const quoteUris = unique([note._misskey_quote, note.quoteUrl].filter(x => x != null)); + const quoteFetchResults = await Promise.all(quoteUris.map(uri => this.fetchNote(getApId(uri)))); + const quote = quoteFetchResults.filter((x) => x != null).at(0) ?? null; + + if (await this.spamFilterService.isSpam({ + mentionedUsers: apMentions, + visibility, + visibleUsers, + reply: reply, + quote: quote, + user: cachedActor, + })) { + throw new IdentifiableError('e11b3a16-f543-4885-8eb1-66cad131dbfd', 'Notes including mentions, replies, or renotes from remote users are not allowed until user has at least one local follower.'); + } + } + const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser; // 解決した投稿者が凍結されていたらスキップ