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
This commit is contained in:
Jess Porter 2022-03-18 10:11:23 +00:00 committed by GitHub
parent 26ae55cd24
commit 1880287ac4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 228 additions and 6 deletions

View File

@ -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);
}
}

View File

@ -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");
}
}

View File

@ -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<any, any> };
/*
* 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<any> {
return Promise.resolve(null);
async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<Consequence | any> {
}
/*
* 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<any> {
return Promise.resolve(null);
async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
}
/**

View File

@ -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) {}
}

View File

@ -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
});
});