From d5f260b982e9b3b9ddd818020c7ab543752c956d Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 4 Dec 2019 18:46:29 -0700 Subject: [PATCH] Add a protection system with a "first message is an image" demo --- src/Mjolnir.ts | 68 +++++++++++++++++++++++++ src/commands/CommandHandler.ts | 10 ++++ src/commands/ProtectionsCommands.ts | 61 ++++++++++++++++++++++ src/protections/FirstMessageIsImage.ts | 70 ++++++++++++++++++++++++++ src/protections/IProtection.ts | 26 ++++++++++ src/protections/protections.ts | 33 ++++++++++++ 6 files changed, 268 insertions(+) create mode 100644 src/commands/ProtectionsCommands.ts create mode 100644 src/protections/FirstMessageIsImage.ts create mode 100644 src/protections/IProtection.ts create mode 100644 src/protections/protections.ts diff --git a/src/Mjolnir.ts b/src/Mjolnir.ts index 7a86b6f..508d5b5 100644 --- a/src/Mjolnir.ts +++ b/src/Mjolnir.ts @@ -23,6 +23,8 @@ import { applyUserBans } from "./actions/ApplyBan"; import config from "./config"; import { logMessage } from "./LogProxy"; import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache"; +import { IProtection } from "./protections/IProtection"; +import { PROTECTIONS } from "./protections/protections"; export const STATE_NOT_STARTED = "not_started"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; @@ -30,12 +32,14 @@ export const STATE_SYNCING = "syncing"; export const STATE_RUNNING = "running"; const WATCHED_LISTS_EVENT_TYPE = "org.matrix.mjolnir.watched_lists"; +const ENABLED_PROTECTIONS_EVENT_TYPE = "org.matrix.mjolnir.enabled_protections"; export class Mjolnir { private displayName: string; private localpart: string; private currentState: string = STATE_NOT_STARTED; + private protections: IProtection[] = []; constructor( public readonly client: MatrixClient, @@ -81,6 +85,10 @@ export class Mjolnir { return this.currentState; } + public get enabledProtections(): IProtection[] { + return this.protections; + } + public start() { return this.client.start().then(async () => { this.currentState = STATE_CHECKING_PERMISSIONS; @@ -94,6 +102,7 @@ export class Mjolnir { await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.buildWatchedBanLists(); await this.syncLists(config.verboseLogging); + await this.enableProtections(); } }).then(async () => { this.currentState = STATE_RUNNING; @@ -101,6 +110,53 @@ export class Mjolnir { }); } + private async getEnabledProtections() { + let enabled: string[] = []; + try { + const protections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE); + if (protections && protections['enabled']) { + for (const protection of protections['enabled']) { + enabled.push(protection); + } + } + } catch (e) { + LogService.warn("Mjolnir", e); + } + + return enabled; + } + + private async enableProtections() { + for (const protection of await this.getEnabledProtections()) { + try { + this.enableProtection(protection, false); + } catch (e) { + LogService.warn("Mjolnir", e); + } + } + } + + public async enableProtection(protectionName: string, persist = true): Promise { + const definition = PROTECTIONS[protectionName]; + if (!definition) throw new Error("Failed to find protection by name: " + protectionName); + + const protection = definition.factory(); + this.protections.push(protection); + + if (persist) { + const existing = this.protections.map(p => p.name); + await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing}); + } + } + + public async disableProtection(protectionName: string): Promise { + const idx = this.protections.findIndex(p => p.name === protectionName); + if (idx >= 0) this.protections.splice(idx, 1); + + const existing = this.protections.map(p => p.name); + await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing}); + } + public async watchList(roomRef: string): Promise { const joinedRooms = await this.client.getJoinedRooms(); const permalink = Permalinks.parseUrl(roomRef); @@ -325,6 +381,18 @@ export class Mjolnir { if (Object.keys(this.protectedRooms).includes(roomId)) { if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves + + // Iterate all the protections + for (const protection of this.protections) { + try { + await protection.handleEvent(this, roomId, event); + } catch (e) { + LogService.error("Mjolnir", "Error handling protection: " + protection.name); + LogService.error("Mjolnir", e); + await this.client.sendNotice(config.managementRoom, "There was an error processing an event through a protection - see log for details."); + } + } + if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') { // power levels were updated - recheck permissions ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION); diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index 617ad58..6385671 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -28,6 +28,7 @@ import { execRedactCommand } from "./RedactCommand"; import { execImportCommand } from "./ImportCommand"; import { execSetDefaultListCommand } from "./SetDefaultBanListCommand"; import { execDeactivateCommand } from "./DeactivateCommand"; +import { execDisableProtection, execEnableProtection, execListProtections } from "./ProtectionsCommands"; export const COMMAND_PREFIX = "!mjolnir"; @@ -62,6 +63,12 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir return await execSetDefaultListCommand(roomId, event, mjolnir, parts); } else if (parts[1] === 'deactivate' && parts.length > 2) { return await execDeactivateCommand(roomId, event, mjolnir, parts); + } else if (parts[1] === 'protections') { + return await execListProtections(roomId, event, mjolnir, parts); + } else if (parts[1] === 'enable' && parts.length > 1) { + return await execEnableProtection(roomId, event, mjolnir, parts); + } else if (parts[1] === 'disable' && parts.length > 1) { + return await execDisableProtection(roomId, event, mjolnir, parts); } else { // Help menu const menu = "" + @@ -79,6 +86,9 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir "!mjolnir import - Imports bans and ACLs into the given list\n" + "!mjolnir default - Sets the default list for commands\n" + "!mjolnir deactivate - Deactivates a user ID\n" + + "!mjolnir protections - List all available protections\n" + + "!mjolnir enable - Enables a particular protection\n" + + "!mjolnir disable - Disables a particular protection\n" + "!mjolnir help - This menu\n"; const html = `Mjolnir help:
${htmlEscape(menu)}
`; const text = `Mjolnir help:\n${menu}`; diff --git a/src/commands/ProtectionsCommands.ts b/src/commands/ProtectionsCommands.ts new file mode 100644 index 0000000..60acccf --- /dev/null +++ b/src/commands/ProtectionsCommands.ts @@ -0,0 +1,61 @@ +/* +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 { Mjolnir } from "../Mjolnir"; +import { LogService, RichReply } from "matrix-bot-sdk"; +import { PROTECTIONS } from "../protections/protections"; + +// !mjolnir enable +export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + try { + await mjolnir.enableProtection(parts[2]); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); + } catch (e) { + LogService.error("ProtectionsCommands", e); + + const message = `Error enabling protection '${parts[0]}' - check the name and try again.`; + const reply = RichReply.createFor(roomId, event, message, message); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); + } +} + +// !mjolnir disable +export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + await mjolnir.disableProtection(parts[2]); + await mjolnir.client.unstableApis.addReactionToEvent(roomId, event['event_id'], '✅'); +} + +// !mjolnir protections +export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) { + const possibleProtections = Object.keys(PROTECTIONS); + const enabledProtections = mjolnir.enabledProtections.map(p => p.name); + + let html = "Available protections:
    "; + let text = "Available protections:\n"; + + for (const protection of possibleProtections) { + const emoji = enabledProtections.includes(protection) ? '🟢' : '🔴'; // green vs red circles + html += `
  • ${emoji} ${protection} - ${PROTECTIONS[protection].description}
  • `; + text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`; + } + + html += "
"; + + const reply = RichReply.createFor(roomId, event, text, html); + reply["msgtype"] = "m.notice"; + await mjolnir.client.sendMessage(roomId, reply); +} diff --git a/src/protections/FirstMessageIsImage.ts b/src/protections/FirstMessageIsImage.ts new file mode 100644 index 0000000..857fdff --- /dev/null +++ b/src/protections/FirstMessageIsImage.ts @@ -0,0 +1,70 @@ +/* +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 { IProtection } from "./IProtection"; +import { Mjolnir } from "../Mjolnir"; +import { LogLevel, LogService } from "matrix-bot-sdk"; +import { logMessage } from "../LogProxy"; + +export class FirstMessageIsImage implements IProtection { + + public justJoined: { [roomId: string]: string[] } = {}; + + constructor() { + } + + public get name(): string { + return 'FirstMessageIsImageProtection'; + } + + public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise { + if (!this.justJoined[roomId]) this.justJoined[roomId] = []; + + if (event['type'] === 'm.room.member') { + const membership = event['content']['membership'] || 'join'; + let prevMembership = "leave"; + if (event['unsigned'] && event['unsigned']['prev_content']) { + prevMembership = event['unsigned']['prev_content']['membership'] || 'leave'; + } + + // We look at the previous membership to filter out profile changes + if (membership === 'join' && prevMembership !== "join") { + this.justJoined[roomId].push(event['state_key']); + LogService.info("FirstMessageIsImage", `Tracking ${event['state_key']} in ${roomId} as just joined`); + } + + return; // stop processing (membership event spam is another problem) + } + + if (event['type'] === 'm.room.message') { + const content = event['content'] || {}; + const msgtype = content['msgtype'] || 'm.text'; + const formattedBody = content['formatted_body'] || ''; + const isMedia = msgtype === 'm.image' || msgtype === 'm.video' || formattedBody.toLowerCase().includes('= 0) { + LogService.info("FirstMessageIsImage", `${event['sender']} is no longer considered suspect`); + this.justJoined[roomId].splice(idx, 1); + } + } +} diff --git a/src/protections/IProtection.ts b/src/protections/IProtection.ts new file mode 100644 index 0000000..e92c573 --- /dev/null +++ b/src/protections/IProtection.ts @@ -0,0 +1,26 @@ +/* +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 { 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) + */ +export interface IProtection { + readonly name: string; + handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise; +} diff --git a/src/protections/protections.ts b/src/protections/protections.ts new file mode 100644 index 0000000..89e2a31 --- /dev/null +++ b/src/protections/protections.ts @@ -0,0 +1,33 @@ +/* +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 { FirstMessageIsImage } from "./FirstMessageIsImage"; +import { IProtection } from "./IProtection"; + +export const PROTECTIONS: PossibleProtections = { + [new FirstMessageIsImage().name]: { + description: "If the first thing a user does after joining is to post an image or video, " + + "they'll be banned for spam. This does not publish the ban to any of your ban lists.", + factory: () => new FirstMessageIsImage(), + }, +}; + +export interface PossibleProtections { + [name: string]: { + description: string; + factory: () => IProtection; + }; +}