From ea58b0b5b5826cc15ae43eebe7a40358cd7d91ce Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 15:35:09 +0100 Subject: [PATCH 01/17] Add mention spam protection. --- src/protections/MentionSpam.ts | 88 ++++++++++++++++++++++++++++ src/protections/ProtectionManager.ts | 8 ++- 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 src/protections/MentionSpam.ts diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts new file mode 100644 index 0000000..c6497e1 --- /dev/null +++ b/src/protections/MentionSpam.ts @@ -0,0 +1,88 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Protection } from "./IProtection"; +import { Mjolnir } from "../Mjolnir"; +import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; +import { NumberProtectionSetting } from "./ProtectionSettings"; + +const MAX_MENTIONS = 8; +const USER_ID_REGEX = /@[^:]*:.+/; + +export class MentionSpam extends Protection { + + settings = { + maxMentions: new NumberProtectionSetting(MAX_MENTIONS), + }; + + constructor() { + super(); + } + + public get name(): string { + return 'MentionSpam'; + } + public get description(): string { + return "If a user posts many mentions, that message is redacted. No bans are issued."; + } + + public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean { + const max = this.settings.maxMentions.value; + if (Array.isArray(mentionArray)) { + if (mentionArray.length > this.settings.maxMentions.value) { + return true; + } + } + if (typeof body === "string") { + let found = 0; + for (const word of body.split(/\s/)) { + if (USER_ID_REGEX.test(word.trim())) { + if (found++ > max) { + return true; + } + } + } + } + if (typeof htmlBody === "string") { + let found = 0; + for (const word of htmlBody.split(/\s/)) { + if (USER_ID_REGEX.test(word.trim())) { + if (found++ > max) { + return true; + } + } + } + } + return false; + } + + public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + if (event['type'] === 'm.room.message') { + let content = event['content'] || {}; + const explicitMentions = content["m.mentions"]?.["m.user_ids"]; + const hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); + if (hitLimit) { + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for spamming mentions. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); + // Redact the event + if (!mjolnir.config.noop) { + await mjolnir.client.redactEvent(roomId, event['event_id'], "Message was detected as spam."); + } else { + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + } + } + } + } +} diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 9bd1e2c..525fc68 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2024 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,7 +24,7 @@ import { MessageIsMedia } from "./MessageIsMedia"; import { TrustedReporters } from "./TrustedReporters"; import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit"; import { Mjolnir } from "../Mjolnir"; -import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk"; +import { extractRequestError, LogLevel, LogService, Permalinks } from "@vector-im/matrix-bot-sdk"; import { ProtectionSettingValidationError } from "./ProtectionSettings"; import { Consequence } from "./consequence"; import { htmlEscape } from "../utils"; @@ -32,6 +32,7 @@ import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache"; import { RoomUpdateError } from "../models/RoomUpdateError"; import { LocalAbuseReports } from "./LocalAbuseReports"; import {NsfwProtection} from "./NsfwProtection"; +import { MentionSpam } from "./MentionSpam"; const PROTECTIONS: Protection[] = [ new FirstMessageIsImage(), @@ -43,7 +44,8 @@ const PROTECTIONS: Protection[] = [ new DetectFederationLag(), new JoinWaveShortCircuit(), new LocalAbuseReports(), - new NsfwProtection() + new NsfwProtection(), + new MentionSpam() ]; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; From 923c00a1ddc300a3841211dfbb024f60f5c45430 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 15:50:50 +0100 Subject: [PATCH 02/17] Add test for mention spam. --- src/protections/MentionSpam.ts | 4 +- test/integration/mentionSpamProtectionTest.ts | 93 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 test/integration/mentionSpamProtectionTest.ts diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index c6497e1..0eb253c 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -19,13 +19,13 @@ import { Mjolnir } from "../Mjolnir"; import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; -const MAX_MENTIONS = 8; +export const DEFAULT_MAX_MENTIONS = 8; const USER_ID_REGEX = /@[^:]*:.+/; export class MentionSpam extends Protection { settings = { - maxMentions: new NumberProtectionSetting(MAX_MENTIONS), + maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), }; constructor() { diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts new file mode 100644 index 0000000..6d981bd --- /dev/null +++ b/test/integration/mentionSpamProtectionTest.ts @@ -0,0 +1,93 @@ +import {newTestUser} from "./clientHelper"; + +import {MatrixClient} from "matrix-bot-sdk"; +import {getFirstReaction} from "./commands/commandUtils"; +import {strict as assert} from "assert"; +import { DEFAULT_MAX_MENTIONS } from "../../src/protections/MentionSpam"; + +describe("Test: Mention spam protection", function () { + let client: MatrixClient; + let room: string; + this.beforeEach(async function () { + client = await newTestUser(this.config.homeserverUrl, {name: {contains: "mention-spam-protection"}}); + await client.start(); + const mjolnirId = await this.mjolnir.client.getUserId(); + room = await client.createRoom({ invite: [mjolnirId] }); + await client.joinRoom(room); + await client.joinRoom(this.config.managementRoom); + await client.setUserPowerLevel(mjolnirId, room, 100); + }) + this.afterEach(async function () { + await client.stop(); + }) + + function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + + it("does not redact a normal message", async function() { + await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); + await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + }); + const testMessage = await client.sendText(room, 'Hello world'); + + await delay(500); + const fetchedEvent = await client.getEvent(room, testMessage); + assert.equal(Object.keys(fetchedEvent.content).length, 3, "This event should not have been redacted"); + }); + + it("does not redact a message with some mentions", async function() { + await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); + await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + }); + // Also covers HTML mentions + const messageWithTextMentions = await client.sendText(room, 'Hello world @foo:bar @beep:boop @test:here'); + const messageWithMMentions = await client.sendMessage(room, { + content: { + msgtype: 'm.text', + body: 'Hello world', + ['m.mentions']: { + user_ids: [ + "@foo:bar", + "@beep:boop", + "@test:here" + ] + } + } + }); + + await delay(500); + const fetchedTextEvent = await client.getEvent(room, messageWithTextMentions); + assert.equal(Object.keys(fetchedTextEvent.content).length, 3, "This event should not have been redacted"); + const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); + assert.equal(Object.keys(fetchedMentionsEvent.content).length, 3, "This event should not have been redacted"); + }); + + it("does redact a message with too many mentions", async function() { + await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir rooms add ${room}` }); + await getFirstReaction(client, this.mjolnir.managementRoomId, '✅', async () => { + return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); + }); + // Also covers HTML mentions + const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS}, (_, i) => `@user${i}:example.org`); + const messageWithTextMentions = await client.sendText(room, 'Hello world ' + mentionUsers.join(' ')); + const messageWithMMentions = await client.sendMessage(room, { + content: { + msgtype: 'm.text', + body: 'Hello world', + ['m.mentions']: { + user_ids: mentionUsers + } + } + }); + + await delay(500); + const fetchedTextEvent = await client.getEvent(room, messageWithTextMentions); + assert.equal(Object.keys(fetchedTextEvent.content).length, 0, "This event should have been redacted"); + const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); + assert.equal(Object.keys(fetchedMentionsEvent.content).length, 0, "This event should have been redacted"); + }); +}); \ No newline at end of file From 02de47e83f9284d66823170046448d5cb17251ad Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 15:54:35 +0100 Subject: [PATCH 03/17] fix imports --- src/protections/MentionSpam.ts | 2 +- src/protections/ProtectionManager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 0eb253c..0d0715c 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -16,7 +16,7 @@ limitations under the License. import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; -import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; +import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; export const DEFAULT_MAX_MENTIONS = 8; diff --git a/src/protections/ProtectionManager.ts b/src/protections/ProtectionManager.ts index 525fc68..06789d9 100644 --- a/src/protections/ProtectionManager.ts +++ b/src/protections/ProtectionManager.ts @@ -24,7 +24,7 @@ import { MessageIsMedia } from "./MessageIsMedia"; import { TrustedReporters } from "./TrustedReporters"; import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit"; import { Mjolnir } from "../Mjolnir"; -import { extractRequestError, LogLevel, LogService, Permalinks } from "@vector-im/matrix-bot-sdk"; +import { extractRequestError, LogLevel, LogService, Permalinks } from "matrix-bot-sdk"; import { ProtectionSettingValidationError } from "./ProtectionSettings"; import { Consequence } from "./consequence"; import { htmlEscape } from "../utils"; From fa9a17cf44e4804518765d0820c3f0b47f498fe5 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 15:55:53 +0100 Subject: [PATCH 04/17] fix log line --- src/protections/MentionSpam.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 0d0715c..e52c55d 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -75,12 +75,12 @@ export class MentionSpam extends Protection { const explicitMentions = content["m.mentions"]?.["m.user_ids"]; const hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); if (hitLimit) { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Redacting event from ${event['sender']} for spamming mentions. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MentionSpam", `Redacting event from ${event['sender']} for spamming mentions. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); // Redact the event if (!mjolnir.config.noop) { await mjolnir.client.redactEvent(roomId, event['event_id'], "Message was detected as spam."); } else { - await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MessageIsMedia", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); + await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MentionSpam", `Tried to redact ${event['event_id']} in ${roomId} but Mjolnir is running in no-op mode`, roomId); } } } From 4ee523e2c0ca6bf254ee8d353f0483180cb3bf38 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 15:59:06 +0100 Subject: [PATCH 05/17] increase to 10 --- src/protections/MentionSpam.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index e52c55d..c230018 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -19,13 +19,13 @@ import { Mjolnir } from "../Mjolnir"; import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; -export const DEFAULT_MAX_MENTIONS = 8; +export const DEFAULT_MAX_MENTIONS = 10; const USER_ID_REGEX = /@[^:]*:.+/; export class MentionSpam extends Protection { settings = { - maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), + maxMentionsRedact: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), }; constructor() { From 38580c7a46eff3469eb8821cc37c52f8b546a7cd Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 16:01:03 +0100 Subject: [PATCH 06/17] fix --- src/protections/MentionSpam.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index c230018..2157378 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -25,7 +25,7 @@ const USER_ID_REGEX = /@[^:]*:.+/; export class MentionSpam extends Protection { settings = { - maxMentionsRedact: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), + maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), }; constructor() { From b9d4f217866907d1c8685b5366e640cd4ac21561 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 16:01:35 +0100 Subject: [PATCH 07/17] add a minimum --- src/protections/MentionSpam.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 2157378..b4e0e6f 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -25,7 +25,7 @@ const USER_ID_REGEX = /@[^:]*:.+/; export class MentionSpam extends Protection { settings = { - maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS), + maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS, 1), }; constructor() { From c77d57b842f507602c149509faf3fefdb67eb12e Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 16:17:37 +0100 Subject: [PATCH 08/17] fix tests --- test/integration/mentionSpamProtectionTest.ts | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index 6d981bd..f7694bb 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -35,7 +35,7 @@ describe("Test: Mention spam protection", function () { await delay(500); const fetchedEvent = await client.getEvent(room, testMessage); - assert.equal(Object.keys(fetchedEvent.content).length, 3, "This event should not have been redacted"); + assert.equal(Object.keys(fetchedEvent.content).length, 2, "This event should not have been redacted"); }); it("does not redact a message with some mentions", async function() { @@ -46,22 +46,20 @@ describe("Test: Mention spam protection", function () { // Also covers HTML mentions const messageWithTextMentions = await client.sendText(room, 'Hello world @foo:bar @beep:boop @test:here'); const messageWithMMentions = await client.sendMessage(room, { - content: { - msgtype: 'm.text', - body: 'Hello world', - ['m.mentions']: { - user_ids: [ - "@foo:bar", - "@beep:boop", - "@test:here" - ] - } + msgtype: 'm.text', + body: 'Hello world', + ['m.mentions']: { + user_ids: [ + "@foo:bar", + "@beep:boop", + "@test:here" + ] } }); await delay(500); const fetchedTextEvent = await client.getEvent(room, messageWithTextMentions); - assert.equal(Object.keys(fetchedTextEvent.content).length, 3, "This event should not have been redacted"); + assert.equal(Object.keys(fetchedTextEvent.content).length, 2, "This event should not have been redacted"); const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); assert.equal(Object.keys(fetchedMentionsEvent.content).length, 3, "This event should not have been redacted"); }); @@ -75,12 +73,10 @@ describe("Test: Mention spam protection", function () { const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS}, (_, i) => `@user${i}:example.org`); const messageWithTextMentions = await client.sendText(room, 'Hello world ' + mentionUsers.join(' ')); const messageWithMMentions = await client.sendMessage(room, { - content: { - msgtype: 'm.text', - body: 'Hello world', - ['m.mentions']: { - user_ids: mentionUsers - } + msgtype: 'm.text', + body: 'Hello world', + ['m.mentions']: { + user_ids: mentionUsers } }); From fadc16bc4c06889936859e1603908bfdac8b672c Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 16:26:27 +0100 Subject: [PATCH 09/17] Update deps --- src/protections/MentionSpam.ts | 2 +- test/integration/mentionSpamProtectionTest.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index b4e0e6f..92870f4 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -16,7 +16,7 @@ limitations under the License. import { Protection } from "./IProtection"; import { Mjolnir } from "../Mjolnir"; -import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk"; +import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; export const DEFAULT_MAX_MENTIONS = 10; diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index f7694bb..fdaa68d 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -1,6 +1,6 @@ import {newTestUser} from "./clientHelper"; -import {MatrixClient} from "matrix-bot-sdk"; +import {MatrixClient} from "@vector-im/matrix-bot-sdk"; import {getFirstReaction} from "./commands/commandUtils"; import {strict as assert} from "assert"; import { DEFAULT_MAX_MENTIONS } from "../../src/protections/MentionSpam"; From 5d497b4ca026519cbc367c3e2e5a1ec2930407cd Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 16:43:44 +0100 Subject: [PATCH 10/17] plus one --- test/integration/mentionSpamProtectionTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index fdaa68d..1aedfb1 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -70,7 +70,7 @@ describe("Test: Mention spam protection", function () { return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); }); // Also covers HTML mentions - const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS}, (_, i) => `@user${i}:example.org`); + const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `@user${i}:example.org`); const messageWithTextMentions = await client.sendText(room, 'Hello world ' + mentionUsers.join(' ')); const messageWithMMentions = await client.sendMessage(room, { msgtype: 'm.text', From ec0c8b7484cceb8128fdc42109184ac4ee6966c0 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 22:28:11 +0100 Subject: [PATCH 11/17] Move operator --- src/protections/MentionSpam.ts | 4 ++-- test/integration/mentionSpamProtectionTest.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 92870f4..5093f2b 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -50,7 +50,7 @@ export class MentionSpam extends Protection { let found = 0; for (const word of body.split(/\s/)) { if (USER_ID_REGEX.test(word.trim())) { - if (found++ > max) { + if (++found > max) { return true; } } @@ -60,7 +60,7 @@ export class MentionSpam extends Protection { let found = 0; for (const word of htmlBody.split(/\s/)) { if (USER_ID_REGEX.test(word.trim())) { - if (found++ > max) { + if (++found > max) { return true; } } diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index 1aedfb1..c21b4c0 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -71,7 +71,7 @@ describe("Test: Mention spam protection", function () { }); // Also covers HTML mentions const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `@user${i}:example.org`); - const messageWithTextMentions = await client.sendText(room, 'Hello world ' + mentionUsers.join(' ')); + const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' ')); const messageWithMMentions = await client.sendMessage(room, { msgtype: 'm.text', body: 'Hello world', From 0c375992eaca0f8a72fba27843a5a98bd0813134 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Thu, 19 Sep 2024 22:33:15 +0100 Subject: [PATCH 12/17] fix typo --- src/protections/MentionSpam.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 5093f2b..483862b 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -72,7 +72,7 @@ export class MentionSpam extends Protection { public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { if (event['type'] === 'm.room.message') { let content = event['content'] || {}; - const explicitMentions = content["m.mentions"]?.["m.user_ids"]; + const explicitMentions = content["m.mentions"]?.user_ids; const hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions); if (hitLimit) { await mjolnir.managementRoomOutput.logMessage(LogLevel.WARN, "MentionSpam", `Redacting event from ${event['sender']} for spamming mentions. ${Permalinks.forEvent(roomId, event['event_id'], [new UserID(await mjolnir.client.getUserId()).domain])}`); From b61f39db2c11b2665ea51ec11e1f2f8c05e06737 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 20 Sep 2024 09:12:55 +0100 Subject: [PATCH 13/17] Test HTML messages --- test/integration/mentionSpamProtectionTest.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/test/integration/mentionSpamProtectionTest.ts b/test/integration/mentionSpamProtectionTest.ts index c21b4c0..28c9d86 100644 --- a/test/integration/mentionSpamProtectionTest.ts +++ b/test/integration/mentionSpamProtectionTest.ts @@ -34,6 +34,7 @@ describe("Test: Mention spam protection", function () { const testMessage = await client.sendText(room, 'Hello world'); await delay(500); + const fetchedEvent = await client.getEvent(room, testMessage); assert.equal(Object.keys(fetchedEvent.content).length, 2, "This event should not have been redacted"); }); @@ -44,22 +45,26 @@ describe("Test: Mention spam protection", function () { return await client.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: "!mjolnir enable MentionSpam" }); }); // Also covers HTML mentions - const messageWithTextMentions = await client.sendText(room, 'Hello world @foo:bar @beep:boop @test:here'); + const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS}, (_, i) => `@user${i}:example.org`); + const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' ')); + const messageWithHTMLMentions = await client.sendHtmlText(room, + mentionUsers.map(u => `${u}`).join(' ')); const messageWithMMentions = await client.sendMessage(room, { msgtype: 'm.text', body: 'Hello world', ['m.mentions']: { - user_ids: [ - "@foo:bar", - "@beep:boop", - "@test:here" - ] + user_ids: mentionUsers } }); await delay(500); + const fetchedTextEvent = await client.getEvent(room, messageWithTextMentions); assert.equal(Object.keys(fetchedTextEvent.content).length, 2, "This event should not have been redacted"); + + const fetchedHTMLEvent = await client.getEvent(room, messageWithHTMLMentions); + assert.equal(Object.keys(fetchedHTMLEvent.content).length, 4, "This event should not have been redacted"); + const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); assert.equal(Object.keys(fetchedMentionsEvent.content).length, 3, "This event should not have been redacted"); }); @@ -72,6 +77,8 @@ describe("Test: Mention spam protection", function () { // Also covers HTML mentions const mentionUsers = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `@user${i}:example.org`); const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' ')); + const messageWithHTMLMentions = await client.sendHtmlText(room, + mentionUsers.map(u => `${u}`).join(' ')); const messageWithMMentions = await client.sendMessage(room, { msgtype: 'm.text', body: 'Hello world', @@ -81,8 +88,13 @@ describe("Test: Mention spam protection", function () { }); await delay(500); + const fetchedTextEvent = await client.getEvent(room, messageWithTextMentions); assert.equal(Object.keys(fetchedTextEvent.content).length, 0, "This event should have been redacted"); + + const fetchedHTMLEvent = await client.getEvent(room, messageWithHTMLMentions); + assert.equal(Object.keys(fetchedHTMLEvent.content).length, 0, "This event should have been redacted"); + const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions); assert.equal(Object.keys(fetchedMentionsEvent.content).length, 0, "This event should have been redacted"); }); From 19e07a64b5591544f02934df5dfcf06087654914 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 20 Sep 2024 09:13:14 +0100 Subject: [PATCH 14/17] Test sigil instead. --- src/protections/MentionSpam.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 483862b..8370003 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -20,7 +20,7 @@ import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; export const DEFAULT_MAX_MENTIONS = 10; -const USER_ID_REGEX = /@[^:]*:.+/; +const USER_ID_SIGIL_REGEX = /(@|%40)/; export class MentionSpam extends Protection { @@ -49,7 +49,7 @@ export class MentionSpam extends Protection { if (typeof body === "string") { let found = 0; for (const word of body.split(/\s/)) { - if (USER_ID_REGEX.test(word.trim())) { + if (USER_ID_SIGIL_REGEX.test(word.trim())) { if (++found > max) { return true; } @@ -59,7 +59,7 @@ export class MentionSpam extends Protection { if (typeof htmlBody === "string") { let found = 0; for (const word of htmlBody.split(/\s/)) { - if (USER_ID_REGEX.test(word.trim())) { + if (USER_ID_SIGIL_REGEX.test(word.trim())) { if (++found > max) { return true; } From 4ec3f3006949e5891bdfd7060c25719c2dfa524c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 20 Sep 2024 09:13:53 +0100 Subject: [PATCH 15/17] Short circuit on short messages --- src/protections/MentionSpam.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 8370003..418774c 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -41,12 +41,13 @@ export class MentionSpam extends Protection { public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean { const max = this.settings.maxMentions.value; + const minMessageLength = max * 3; // "@:a" if (Array.isArray(mentionArray)) { if (mentionArray.length > this.settings.maxMentions.value) { return true; } } - if (typeof body === "string") { + if (typeof body === "string" && body.length > minMessageLength) { let found = 0; for (const word of body.split(/\s/)) { if (USER_ID_SIGIL_REGEX.test(word.trim())) { @@ -56,7 +57,7 @@ export class MentionSpam extends Protection { } } } - if (typeof htmlBody === "string") { + if (typeof htmlBody === "string" && htmlBody.length > minMessageLength) { let found = 0; for (const word of htmlBody.split(/\s/)) { if (USER_ID_SIGIL_REGEX.test(word.trim())) { From a51cd63ddec7bed49cf04dfb4aecbaa79d189361 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 20 Sep 2024 09:22:43 +0100 Subject: [PATCH 16/17] Optimise further --- src/protections/MentionSpam.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 418774c..7df348c 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -20,7 +20,6 @@ import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk"; import { NumberProtectionSetting } from "./ProtectionSettings"; export const DEFAULT_MAX_MENTIONS = 10; -const USER_ID_SIGIL_REGEX = /(@|%40)/; export class MentionSpam extends Protection { @@ -41,31 +40,16 @@ export class MentionSpam extends Protection { public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean { const max = this.settings.maxMentions.value; - const minMessageLength = max * 3; // "@:a" if (Array.isArray(mentionArray)) { if (mentionArray.length > this.settings.maxMentions.value) { return true; } } - if (typeof body === "string" && body.length > minMessageLength) { - let found = 0; - for (const word of body.split(/\s/)) { - if (USER_ID_SIGIL_REGEX.test(word.trim())) { - if (++found > max) { - return true; - } - } - } + if (typeof body === "string" && body.split('@').length - 1 > max) { + return true; } - if (typeof htmlBody === "string" && htmlBody.length > minMessageLength) { - let found = 0; - for (const word of htmlBody.split(/\s/)) { - if (USER_ID_SIGIL_REGEX.test(word.trim())) { - if (++found > max) { - return true; - } - } - } + if (typeof htmlBody === "string" && htmlBody.split('%40').length - 1 > max) { + return true; } return false; } From 0c75c179209fa0a00d04879e57e4427f9e8f5e02 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 20 Sep 2024 09:26:12 +0100 Subject: [PATCH 17/17] Reduce logic further --- src/protections/MentionSpam.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/protections/MentionSpam.ts b/src/protections/MentionSpam.ts index 7df348c..3abed22 100644 --- a/src/protections/MentionSpam.ts +++ b/src/protections/MentionSpam.ts @@ -40,10 +40,8 @@ export class MentionSpam extends Protection { public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean { const max = this.settings.maxMentions.value; - if (Array.isArray(mentionArray)) { - if (mentionArray.length > this.settings.maxMentions.value) { - return true; - } + if (Array.isArray(mentionArray) && mentionArray.length > max) { + return true; } if (typeof body === "string" && body.split('@').length - 1 > max) { return true;