From 1880287ac49d9c59724184e2be190f72b093b0e6 Mon Sep 17 00:00:00 2001 From: Jess Porter Date: Fri, 18 Mar 2022 10:11:23 +0000 Subject: [PATCH] standard protection consequences (#232) * standard protection consequences * add integration test to make sure good users aren't banned * the less far `event` propagates, the better * better document consequence.ts * improve innocent user integration test * switch to room.event emit --- src/Mjolnir.ts | 40 ++++- src/protections/BasicFlooding.ts | 3 + src/protections/IProtection.ts | 8 +- src/protections/consequence.ts | 23 +++ test/integration/standardConsequenceTest.ts | 160 ++++++++++++++++++++ 5 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 src/protections/consequence.ts create mode 100644 test/integration/standardConsequenceTest.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 0c00002..df6eaf7 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -36,6 +36,7 @@ import config from "./config"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; import { Protection } from "./protections/IProtection"; import { PROTECTIONS } from "./protections/protections"; +import { ConsequenceType, Consequence } from "./protections/consequence"; import { ProtectionSettingValidationError } from "./protections/ProtectionSettings"; import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"; import { Healthz } from "./health/healthz"; @@ -63,6 +64,7 @@ const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; const PROTECTED_ROOMS_EVENT_TYPE = "org.matrix.mjolnir.protected_rooms"; const WARN_UNPROTECTED_ROOM_EVENT_PREFIX = "org.matrix.mjolnir.unprotected_room_warning.for."; +const CONSEQUENCE_EVENT_DATA = "org.matrix.mjolnir.consequence"; export class Mjolnir { private displayName: string; @@ -851,6 +853,36 @@ export class Mjolnir { await this.printBanlistChanges(changes, banList, true); } + private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { + switch (consequence.type) { + case ConsequenceType.alert: + break; + case ConsequenceType.redact: + await this.client.redactEvent(roomId, eventId, "abuse detected"); + break; + case ConsequenceType.ban: + await this.client.banUser(sender, roomId, "abuse detected"); + break; + } + + let message = `protection ${protection.name} enacting ${ConsequenceType[consequence.type]} against ${htmlEscape(sender)}`; + if (consequence.reason !== undefined) { + // even though internally-sourced, there's no promise that `consequence.reason` + // will never have user-supplied information, so escape it + message += ` (reason: ${htmlEscape(consequence.reason)})`; + } + + await this.client.sendMessage(this.managementRoomId, { + msgtype: "m.notice", + body: message, + [CONSEQUENCE_EVENT_DATA]: { + who: sender, + room: roomId, + type: ConsequenceType[consequence.type] + } + }); + } + private async handleEvent(roomId: string, event: any) { // Check for UISI errors if (roomId === this.managementRoomId) { @@ -878,14 +910,20 @@ export class Mjolnir { // Iterate all the enabled protections for (const protection of this.enabledProtections) { + let consequence: Consequence | undefined = undefined; try { - await protection.handleEvent(this, roomId, event); + consequence = await protection.handleEvent(this, roomId, event); } catch (e) { const eventPermalink = Permalinks.forEvent(roomId, event['event_id']); LogService.error("Mjolnir", "Error handling protection: " + protection.name); LogService.error("Mjolnir", "Failed event: " + eventPermalink); LogService.error("Mjolnir", extractRequestError(e)); await this.client.sendNotice(this.managementRoomId, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink); + continue; + } + + if (consequence !== undefined) { + await this.handleConsequence(protection, roomId, event["event_id"], event["sender"], consequence); } } diff --git a/src/protections/BasicFlooding.ts b/src/protections/BasicFlooding.ts index 323c739..e7bf383 100644 --- a/src/protections/BasicFlooding.ts +++ b/src/protections/BasicFlooding.ts @@ -18,6 +18,7 @@ import { Protection } from "./IProtection"; import { NumberProtectionSetting } from "./ProtectionSettings"; import { Mjolnir } from "../Mjolnir"; import { LogLevel, LogService } from "matrix-bot-sdk"; +import { Consequence, ConsequenceType } from "./consequence"; import config from "../config"; // if this is exceeded, we'll ban the user for spam and redact their messages @@ -91,5 +92,7 @@ export class BasicFlooding extends Protection { if (forUser.length > this.settings.maxPerMinute.value * 2) { forUser.splice(0, forUser.length - (this.settings.maxPerMinute.value * 2) - 1); } + + return new Consequence(ConsequenceType.ban, "flooding"); } } diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts index b8a8f1b..8346cce 100644 --- a/src/protections/IProtection.ts +++ b/src/protections/IProtection.ts @@ -16,6 +16,7 @@ limitations under the License. import { Mjolnir } from "../Mjolnir"; import { AbstractProtectionSetting } from "./ProtectionSettings"; +import { Consequence } from "./consequence"; /** * Represents a protection mechanism of sorts. Protections are intended to be @@ -29,21 +30,18 @@ export abstract class Protection { enabled = false; abstract settings: { [setting: string]: AbstractProtectionSetting }; - /* * Handle a single event from a protected room, to decide if we need to * respond to it */ - handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { - return Promise.resolve(null); + async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { } /* * Handle a single reported event from a protecte room, to decide if we * need to respond to it */ - handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { - return Promise.resolve(null); + async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise { } /** diff --git a/src/protections/consequence.ts b/src/protections/consequence.ts new file mode 100644 index 0000000..7abf5dc --- /dev/null +++ b/src/protections/consequence.ts @@ -0,0 +1,23 @@ + +/* + * Distinct individual actions that can be caused as a result of detected abuse + */ +export enum ConsequenceType { + // effectively a no-op. just tell the management room + alert, + // redact the event that triggered this consequence + redact, + // ban the user that sent the event that triggered this consequence + ban +} + +export class Consequence { + /* + * Action to take upon detection of abuse and an optional explanation of the detection + * + * @param type Action to take + * @param reason Brief explanation of why we're taking an action, printed to management room. + * this will be HTML escaped before printing, just in case it has user-provided data + */ + constructor(public readonly type: ConsequenceType, public readonly reason?: string) {} +} diff --git a/test/integration/standardConsequenceTest.ts b/test/integration/standardConsequenceTest.ts new file mode 100644 index 0000000..68048fd --- /dev/null +++ b/test/integration/standardConsequenceTest.ts @@ -0,0 +1,160 @@ +import { strict as assert } from "assert"; + +import config from "../../src/config"; +import { Mjolnir } from "../../src/Mjolnir"; +import { IProtection } from "../../src/protections/IProtection"; +import { newTestUser, noticeListener } from "./clientHelper"; +import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; +import { ConsequenceType, Consequence } from "../../src/protections/consequence"; + +describe("Test: standard consequences", function() { + let badUser; + let goodUser; + this.beforeEach(async function () { + badUser = await newTestUser({ name: { contains: "standard-consequences" }}); + goodUser = await newTestUser({ name: { contains: "standard-consequences" }}); + await badUser.start(); + await goodUser.start(); + }) + this.afterEach(async function () { + await badUser.stop(); + await goodUser.stop(); + }) + it("Mjolnir applies a standard consequence redaction", async function() { + this.timeout(20000); + + let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); + await badUser.joinRoom(this.mjolnir.managementRoomId); + await badUser.joinRoom(protectedRoomId); + await this.mjolnir.addProtectedRoom(protectedRoomId); + + await this.mjolnir.registerProtection(new class implements IProtection { + name = "JY2TPN"; + description = "A test protection"; + settings = { }; + handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { + if (event.content.body === "ngmWkF") { + return new Consequence(ConsequenceType.redact, "asd"); + } + }; + }); + await this.mjolnir.enableProtection("JY2TPN"); + + let reply = new Promise(async (resolve, reject) => { + const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "ngmWkF"}); + let redaction; + badUser.on('room.event', (roomId, event) => { + if ( + roomId === protectedRoomId + && event?.type === "m.room.redaction" + && event.redacts === messageId + ) { + redaction = event + } + if ( + roomId === this.mjolnir.managementRoomId + && event?.type === "m.room.message" + && event?.content?.body?.startsWith("protection JY2TPN enacting redact against ") + && redaction !== undefined + ) { + resolve([redaction, event]) + } + }); + }); + + const [eventRedact, eventMessage] = await reply + }); + it("Mjolnir applies a standard consequence ban", async function() { + this.timeout(20000); + + let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await badUser.getUserId()] }); + await badUser.joinRoom(this.mjolnir.managementRoomId); + await badUser.joinRoom(protectedRoomId); + await this.mjolnir.addProtectedRoom(protectedRoomId); + + await this.mjolnir.registerProtection(new class implements IProtection { + name = "0LxMTy"; + description = "A test protection"; + settings = { }; + handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { + if (event.content.body === "7Uga3d") { + return new Consequence(ConsequenceType.ban, "asd"); + } + }; + }); + await this.mjolnir.enableProtection("0LxMTy"); + + let reply = new Promise(async (resolve, reject) => { + const messageId = await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "7Uga3d"}); + let ban; + badUser.on('room.leave', (roomId, event) => { + if ( + roomId === protectedRoomId + && event?.type === "m.room.member" + && event.content?.membership === "ban" + && event.state_key === badUser.userId + ) { + ban = event; + } + }); + badUser.on('room.event', (roomId, event) => { + if ( + roomId === this.mjolnir.managementRoomId + && event?.type === "m.room.message" + && event?.content?.body?.startsWith("protection 0LxMTy enacting ban against ") + && ban !== undefined + ) { + resolve([ban, event]) + } + }); + }); + + const [eventBan, eventMessage] = await reply + }); + it("Mjolnir doesn't ban a good user", async function() { + this.timeout(20000); + + let protectedRoomId = await this.mjolnir.client.createRoom({ invite: [await goodUser.getUserId()] }); + await badUser.joinRoom(protectedRoomId); + await goodUser.joinRoom(protectedRoomId); + await this.mjolnir.addProtectedRoom(protectedRoomId); + + await this.mjolnir.registerProtection(new class implements IProtection { + name = "95B1Cr"; + description = "A test protection"; + settings = { }; + handleEvent = async (mjolnir: Mjolnir, roomId: string, event: any) => { + if (event.content.body === "8HUnwb") { + return new Consequence(ConsequenceType.ban, "asd"); + } + }; + }); + await this.mjolnir.enableProtection("95B1Cr"); + + let reply = new Promise(async (resolve, reject) => { + this.mjolnir.client.on('room.message', async (roomId, event) => { + if (event?.content?.body === "SUwvFT") { + await badUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "8HUnwb"}); + } + }); + + this.mjolnir.client.on('room.event', (roomId, event) => { + if ( + roomId === protectedRoomId + && event?.type === "m.room.member" + && event.content?.membership === "ban" + ) { + if (event.state_key === goodUser.userId) { + reject("good user has been banned"); + } else if (event.state_key === badUser.userId) { + resolve(null); + } + } + }); + }); + await goodUser.sendMessage(protectedRoomId, {msgtype: "m.text", body: "SUwvFT"}); + + await reply + }); +}); +