Don't spam protection warnings, and ensure the user is redacted

We now always prioritize redaction over ban to ensure that the user gets removed from our rooms. This also means that the second image posted by a spammer is redacted after join.

This commit also improves the messaging a bit.
This commit is contained in:
Travis Ralston 2019-12-09 19:15:51 -07:00
parent 2e3bc5287c
commit f9e3c33935
5 changed files with 87 additions and 8 deletions

View File

@ -25,6 +25,7 @@ import { logMessage } from "./LogProxy";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import { IProtection } from "./protections/IProtection";
import { PROTECTIONS } from "./protections/protections";
import { AutomaticRedactionQueue } from "./queues/AutomaticRedactionQueue";
export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@ -40,6 +41,7 @@ export class Mjolnir {
private localpart: string;
private currentState: string = STATE_NOT_STARTED;
private protections: IProtection[] = [];
private redactionQueue = new AutomaticRedactionQueue();
constructor(
public readonly client: MatrixClient,
@ -89,6 +91,10 @@ export class Mjolnir {
return this.protections;
}
public get redactionHandler(): AutomaticRedactionQueue {
return this.redactionQueue;
}
public start() {
return this.client.start().then(async () => {
this.currentState = STATE_CHECKING_PERMISSIONS;
@ -395,12 +401,18 @@ export class Mjolnir {
try {
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", e);
await this.client.sendNotice(config.managementRoom, "There was an error processing an event through a protection - see log for details.");
await this.client.sendNotice(config.managementRoom, "There was an error processing an event through a protection - see log for details. Event: " + eventPermalink);
}
}
// Run the event handlers - we always run this after protections so that the protections
// can flag the event for redaction.
await this.redactionQueue.handleEvent(roomId, event, this.client);
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);

View File

@ -24,7 +24,8 @@ const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase
export class BasicFlooding implements IProtection {
public lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {};
private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {};
private recentlyBanned: string[] = [];
constructor() {
}
@ -55,12 +56,21 @@ export class BasicFlooding implements IProtection {
}
if (messageCount >= MAX_PER_MINUTE) {
await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`);
await mjolnir.client.banUser(event['sender'], roomId, "spam");
// Prioritize redaction over ban - we can always keep redacting what the user said.
if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted)
mjolnir.redactionHandler.addUser(event['sender']);
this.recentlyBanned.push(event['sender']); // flag to reduce spam
// Redact all the things the user said too
for (const eventId of forUser.map(e => e.eventId)) {
await mjolnir.client.redactEvent(roomId, eventId, "spam");
}
await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`);
await mjolnir.client.banUser(event['sender'], roomId, "spam");
// Free up some memory now that we're ready to handle it elsewhere
forUser = forRoom[event['sender']] = []; // reset the user's list
}

View File

@ -21,7 +21,8 @@ import { logMessage } from "../LogProxy";
export class FirstMessageIsImage implements IProtection {
public justJoined: { [roomId: string]: string[] } = {};
private justJoined: { [roomId: string]: string[] } = {};
private recentlyBanned: string[] = [];
constructor() {
}
@ -55,9 +56,17 @@ export class FirstMessageIsImage implements IProtection {
const formattedBody = content['formatted_body'] || '';
const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('<img');
if (isMedia && this.justJoined[roomId].includes(event['sender'])) {
await logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining.`);
await mjolnir.client.banUser(event['sender'], roomId, "spam");
// Prioritize redaction over ban because we can always keep redacting the user's messages
if (this.recentlyBanned.includes(event['sender'])) return; // already handled (will be redacted)
mjolnir.redactionHandler.addUser(event['sender']);
this.recentlyBanned.push(event['sender']); // flag to reduce spam
// Redact the event
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
await logMessage(LogLevel.WARN, "FirstMessageIsImage", `Banning ${event['sender']} for posting an image as the first thing after joining in ${roomId}.`);
await mjolnir.client.banUser(event['sender'], roomId, "spam");
}
}

View File

@ -18,7 +18,9 @@ import { Mjolnir } from "../Mjolnir";
/**
* Represents a protection mechanism of sorts. Protections are intended to be
* event-based (ie: X messages in a period of time, or posting X events)
* event-based (ie: X messages in a period of time, or posting X events).
*
* Protections are guaranteed to be run before redaction handlers.
*/
export interface IProtection {
readonly name: string;

View File

@ -0,0 +1,46 @@
/*
Copyright 2019 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 { LogLevel, LogService, MatrixClient, Permalinks } from "matrix-bot-sdk";
import { logMessage } from "../LogProxy";
export class AutomaticRedactionQueue {
private usersToRedact: Set<string> = new Set<string>();
constructor() {
}
public addUser(userId: string) {
this.usersToRedact.add(userId);
}
public isUserQueued(userId: string): boolean {
return this.usersToRedact.has(userId);
}
public async handleEvent(roomId: string, event: any, mjolnirClient: MatrixClient) {
if (this.isUserQueued(event['sender'])) {
const permalink = Permalinks.forEvent(roomId, event['event_id']);
try {
LogService.info("AutomaticRedactionQueue", `Redacting event because the user is listed as bad: ${permalink}`)
await mjolnirClient.redactEvent(roomId, event['event_id']);
} catch (e) {
logMessage(LogLevel.WARN, "AutomaticRedactionQueue", `Unable to redact message: ${permalink}`);
LogService.warn("AutomaticRedactionQueue", e);
}
}
}
}