Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Spam Filter before creating user / drive files #24

Merged
merged 1 commit into from
Nov 4, 2024
Merged
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: 3 additions & 0 deletions packages/backend/src/core/CoreModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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用(循環参照対応のため)
Expand Down Expand Up @@ -454,6 +455,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
SpamFilterService,

//#region 文字列ベースでのinjection用(循環参照対応のため)
$VmimiRelayTimelineService,
Expand Down Expand Up @@ -750,6 +752,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApPersonService,
ApQuestionService,
QueueService,
SpamFilterService,

//#region 文字列ベースでのinjection用(循環参照対応のため)
$VmimiRelayTimelineService,
Expand Down
20 changes: 11 additions & 9 deletions packages/backend/src/core/NoteCreateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 94 additions & 0 deletions packages/backend/src/core/SpamFilterService.ts
Original file line number Diff line number Diff line change
@@ -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<string>([]);
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<boolean> {
// 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;
}
}
7 changes: 4 additions & 3 deletions packages/backend/src/core/activitypub/ApAudienceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export class ApAudienceService {
}

@bindThis
public async parseAudience(actor: MiRemoteUser, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
public async parseAudience(actor: MiRemoteUser | null, to?: ApObject, cc?: ApObject, resolver?: Resolver): Promise<AudienceInfo> {
const toGroups = this.groupingAudience(getApIds(to), actor);
const ccGroups = this.groupingAudience(getApIds(cc), actor);

Expand Down Expand Up @@ -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: [],
Expand Down Expand Up @@ -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`);
}
}
42 changes: 41 additions & 1 deletion packages/backend/src/core/activitypub/models/ApNoteService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,6 +73,7 @@ export class ApNoteService {
private noteCreateService: NoteCreateService,
private apDbResolverService: ApDbResolverService,
private apLoggerService: ApLoggerService,
private spamFilterService: SpamFilterService,
) {
this.logger = this.apLoggerService.logger;
}
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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.');
i544c-me marked this conversation as resolved.
Show resolved Hide resolved
}
}

const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;

// 解決した投稿者が凍結されていたらスキップ
Expand Down
Loading