Skip to content

Commit

Permalink
Add displayname mention spam protection (#537)
Browse files Browse the repository at this point in the history
* Add displayname mention spam tracking

* lowercase all comparisons

* fix null access

* add comment

Co-authored-by: Travis Ralston <[email protected]>

---------

Co-authored-by: Half-Shot <[email protected]>
Co-authored-by: Travis Ralston <[email protected]>
  • Loading branch information
3 people authored Oct 4, 2024
1 parent 05d1894 commit c706068
Show file tree
Hide file tree
Showing 4 changed files with 61 additions and 2 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"humanize-duration-ts": "^2.1.1",
"js-yaml": "^4.1.0",
"jsdom": "^16.6.0",
"lru-cache": "^11.0.1",
"matrix-appservice-bridge": "10.3.1",
"nsfwjs": "^4.1.0",
"parse-duration": "^1.0.2",
Expand All @@ -69,5 +70,6 @@
},
"engines": {
"node": ">=20.0.0"
}
},
"packageManager": "[email protected]+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
44 changes: 43 additions & 1 deletion src/protections/MentionSpam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,17 @@ import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, Permalinks, UserID } from "@vector-im/matrix-bot-sdk";
import { NumberProtectionSetting } from "./ProtectionSettings";
import { LRUCache } from "lru-cache";

export const DEFAULT_MAX_MENTIONS = 10;

export class MentionSpam extends Protection {

private roomDisplaynameCache = new LRUCache<string, string[]>({
ttl: 1000 * 60 * 24, // 24 minutes
ttlAutopurge: true,
});

settings = {
maxMentions: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS, 1),
};
Expand All @@ -38,6 +44,22 @@ export class MentionSpam extends Protection {
return "If a user posts many mentions, that message is redacted. No bans are issued.";
}

private async getRoomDisplaynames(mjolnir: Mjolnir, roomId: string): Promise<string[]> {
const existing = this.roomDisplaynameCache.get(roomId);
if (existing) {
return existing;
}
const profiles = await mjolnir.client.getJoinedRoomMembersWithProfiles(roomId);
const displaynames = (Object.values(profiles)
.map(v => v.display_name?.toLowerCase())
.filter(v => typeof v === "string") as string[])
// Limit to displaynames with more than a few characters.
.filter(displayname => displayname.length > 2);

this.roomDisplaynameCache.set(roomId, displaynames);
return displaynames;
}

public checkMentions(body: unknown|undefined, htmlBody: unknown|undefined, mentionArray: unknown|undefined): boolean {
const max = this.settings.maxMentions.value;
if (Array.isArray(mentionArray) && mentionArray.length > max) {
Expand All @@ -52,11 +74,31 @@ export class MentionSpam extends Protection {
return false;
}

public checkDisplaynameMentions(body: unknown|undefined, htmlBody: unknown|undefined, displaynames: string[]): boolean {
const max = this.settings.maxMentions.value;
const bodyWords = ((typeof body === "string" && body) || "").toLowerCase();
if (displaynames.filter(s => bodyWords.includes(s.toLowerCase())).length > max) {
return true;
}
const htmlBodyWords = decodeURIComponent((typeof htmlBody === "string" && htmlBody) || "").toLowerCase();
if (displaynames.filter(s => htmlBodyWords.includes(s)).length > max) {
return true;
}
return false;
}

public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
if (event['type'] === 'm.room.message') {
let content = event['content'] || {};
const explicitMentions = content["m.mentions"]?.user_ids;
const hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions);
let hitLimit = this.checkMentions(content.body, content.formatted_body, explicitMentions);

// Slightly more costly to hit displaynames, so only do it if we don't hit on mxid matches.
if (!hitLimit) {
const displaynames = await this.getRoomDisplaynames(mjolnir, roomId);
hitLimit = this.checkDisplaynameMentions(content.body, content.formatted_body, displaynames);
}

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])}`);
// Redact the event
Expand Down
10 changes: 10 additions & 0 deletions test/integration/mentionSpamProtectionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ 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 mentionDisplaynames = Array.from({length: DEFAULT_MAX_MENTIONS+1}, (_, i) => `Test User ${i}`);

// Pre-set the displayname cache.
let protection = this.mjolnir.protectionManager.protections.get('MentionSpam')
protection.roomDisplaynameCache.set(room, mentionDisplaynames);

const messageWithTextMentions = await client.sendText(room, mentionUsers.join(' '));
const messageWithHTMLMentions = await client.sendHtmlText(room,
mentionUsers.map(u => `<a href=\"https://matrix.to/#/${encodeURIComponent(u)}\">${u}</a>`).join(' '));
Expand All @@ -86,6 +92,7 @@ describe("Test: Mention spam protection", function () {
user_ids: mentionUsers
}
});
const messageWithDisplaynameMentions = await client.sendText(room, mentionDisplaynames.join(' '));

await delay(500);

Expand All @@ -97,5 +104,8 @@ describe("Test: Mention spam protection", function () {

const fetchedMentionsEvent = await client.getEvent(room, messageWithMMentions);
assert.equal(Object.keys(fetchedMentionsEvent.content).length, 0, "This event should have been redacted");

const fetchedDisplaynameEvent = await client.getEvent(room, messageWithDisplaynameMentions);
assert.equal(Object.keys(fetchedDisplaynameEvent.content).length, 0, "This event should have been redacted");
});
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2578,6 +2578,11 @@ lru-cache@^10.0.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==

lru-cache@^11.0.1:
version "11.0.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.1.tgz#3a732fbfedb82c5ba7bca6564ad3f42afcb6e147"
integrity sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==

lru-cache@^4.1.5:
version "4.1.5"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz"
Expand Down

0 comments on commit c706068

Please sign in to comment.