Add a protection system with a "first message is an image" demo

This commit is contained in:
Travis Ralston 2019-12-04 18:46:29 -07:00
parent 4f8b55c45f
commit d5f260b982
6 changed files with 268 additions and 0 deletions

View File

@ -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<any> {
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<any> {
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<BanList> {
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);

View File

@ -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 <room alias/ID> <list shortcode> - Imports bans and ACLs into the given list\n" +
"!mjolnir default <shortcode> - Sets the default list for commands\n" +
"!mjolnir deactivate <user ID> - Deactivates a user ID\n" +
"!mjolnir protections - List all available protections\n" +
"!mjolnir enable <protection> - Enables a particular protection\n" +
"!mjolnir disable <protection> - Disables a particular protection\n" +
"!mjolnir help - This menu\n";
const html = `<b>Mjolnir help:</b><br><pre><code>${htmlEscape(menu)}</code></pre>`;
const text = `Mjolnir help:\n${menu}`;

View File

@ -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 <protection>
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 <protection>
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:<ul>";
let text = "Available protections:\n";
for (const protection of possibleProtections) {
const emoji = enabledProtections.includes(protection) ? '🟢' : '🔴'; // green vs red circles
html += `<li>${emoji} <code>${protection}</code> - ${PROTECTIONS[protection].description}</li>`;
text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`;
}
html += "</ul>";
const reply = RichReply.createFor(roomId, event, text, html);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
}

View File

@ -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<any> {
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('<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");
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
}
}
const idx = this.justJoined[roomId].indexOf(event['sender']);
if (idx >= 0) {
LogService.info("FirstMessageIsImage", `${event['sender']} is no longer considered suspect`);
this.justJoined[roomId].splice(idx, 1);
}
}
}

View File

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

View File

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