Skip to content

Commit

Permalink
Don't ban users in moderator room (#544)
Browse files Browse the repository at this point in the history
* add a list of users/servers who should not be banned, acl'd

* check list of entities who shouldn't be banned before banning

* add command to add users to ignored list

* use correct file when logging

* lint

* add a cache to track ignore list/moderation room members

* refactor to use modcache

* test

* lint

* clear interval in cache when shutting down

* stop managed mjolnirs

* don't join user to ban to management room
  • Loading branch information
H-Shay authored Oct 21, 2024
1 parent 4acf9bb commit 93282da
Show file tree
Hide file tree
Showing 17 changed files with 425 additions and 10 deletions.
11 changes: 11 additions & 0 deletions src/Mjolnir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { RoomMemberManager } from "./RoomMembers";
import ProtectedRoomsConfig from "./ProtectedRoomsConfig";
import { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter";
import { OpenMetrics } from "./webapis/OpenMetrics";
import { ModCache } from "./ModCache";

export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
Expand Down Expand Up @@ -81,6 +82,11 @@ export class Mjolnir {

public readonly policyListManager: PolicyListManager;

/**
* Members of the moderator room and others who should not be banned, ACL'd etc.
*/
public moderators: ModCache;

/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixSendClient} client
Expand Down Expand Up @@ -180,6 +186,9 @@ export class Mjolnir {
"Mjolnir is starting up. Use !mjolnir to query status.",
);
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);

mjolnir.moderators = new ModCache(mjolnir.client, mjolnir.matrixEmitter, mjolnir.managementRoomId);

return mjolnir;
}

Expand Down Expand Up @@ -285,6 +294,7 @@ export class Mjolnir {
this.managementRoomOutput,
this.protectionManager,
config,
this.moderators,
);
}

Expand Down Expand Up @@ -391,6 +401,7 @@ export class Mjolnir {
this.webapis.stop();
this.reportPoller?.stop();
this.openMetrics.stop();
this.moderators.stop();
}

/**
Expand Down
114 changes: 114 additions & 0 deletions src/ModCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
Copyright 2024 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 { MatrixEmitter, MatrixSendClient } from "./MatrixEmitter";

export class ModCache {
private modRoomMembers: string[] = [];
private ignoreList: string[] = [];
private client: MatrixSendClient;
private emitter: MatrixEmitter;
private managementRoomId: string;
private ttl: number = 1000 * 60 * 60; // 60 minutes
private lastInvalidation = 0;
private interval: any;

constructor(client: MatrixSendClient, emitter: MatrixEmitter, managementRoomId: string) {
this.client = client;
this.emitter = emitter;
this.managementRoomId = managementRoomId;
this.lastInvalidation = Date.now();
this.init();
}

/**
* Initially populate cache and set bot listening for membership events in moderation room
*/
async init() {
await this.populateCache();
this.interval = setInterval(
() => {
if (Date.now() - this.lastInvalidation > this.ttl) {
this.populateCache();
}
},
1000 * 60, // check invalidation status every minute
);
this.emitter.on("room.event", async (roomId: string, event: any) => {
if (roomId === this.managementRoomId && event.type === "m.room.member") {
await this.populateCache();
this.lastInvalidation = Date.now();
}
});
}

/**
* Populate the cache by fetching moderation room membership events
*/
public async populateCache() {
const memberEvents = await this.client.getRoomMembers(
this.managementRoomId,
undefined,
["join", "invite"],
["ban", "leave"],
);
this.modRoomMembers = [];
memberEvents.forEach((event) => {
if (!this.modRoomMembers.includes(event.stateKey)) {
this.modRoomMembers.push(event.stateKey);
}
const server = event.stateKey.split(":")[1];
if (!this.modRoomMembers.includes(server)) {
this.modRoomMembers.push(server);
}
});
}

/**
* Check if a given entity is in cache
*/
public checkMembership(entity: string) {
return this.modRoomMembers.includes(entity) || this.ignoreList.includes(entity);
}

/**
* Add a given entity to the list of users/servers who will not be banned but are not necessarily in moderator room
*/
public addToIgnore(entity: string) {
this.ignoreList.push(entity);
}

/**
* Return a list of entities to ignore bans/ACLs for
*/
public listIgnored() {
return this.ignoreList;
}

/**
* Return a list of both ignored entities and moderator room members
*/
public listAll() {
return this.ignoreList.concat(this.modRoomMembers);
}

/**
* Clear the interval which refreshes cache
*/
public stop() {
clearInterval(this.interval);
}
}
11 changes: 11 additions & 0 deletions src/ProtectedRoomsSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ProtectionManager } from "./protections/ProtectionManager";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { htmlEscape } from "./utils";
import { ModCache } from "./ModCache";

/**
* This class aims to synchronize `m.ban` rules in a set of policy lists with
Expand Down Expand Up @@ -113,6 +114,7 @@ export class ProtectedRoomsSet {
private readonly managementRoomOutput: ManagementRoomOutput,
private readonly protectionManager: ProtectionManager,
private readonly config: IConfig,
private readonly moderators: ModCache,
) {
for (const reason of this.config.automaticallyRedactForReasons) {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
Expand Down Expand Up @@ -446,6 +448,15 @@ export class ProtectedRoomsSet {
);

if (!this.config.noop) {
if (this.moderators.checkMembership(member.userId)) {
await this.managementRoomOutput.logMessage(
LogLevel.WARN,
"ApplyBan",
`Attempted
to ban ${member.userId} but this is a member of the management room, skipping.`,
);
continue;
}
await this.client.banUser(member.userId, roomId, memberAccess.rule!.reason);
if (this.automaticRedactGlobs.find((g) => g.test(reason.toLowerCase()))) {
this.redactUser(member.userId, roomId);
Expand Down
1 change: 1 addition & 0 deletions src/appservice/AppService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ export class MjolnirAppService {
await this.dataStore.close();
await this.api.close();
this.openMetrics.stop();
this.mjolnirManager.closeAll();
}

/**
Expand Down
16 changes: 16 additions & 0 deletions src/appservice/MjolnirManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ export class MjolnirManager {
}
}

/**
* Stop all the managed mjolnirs
*/
public closeAll() {
for (const mjolnir of this.perMjolnirId.values()) {
mjolnir.stop();
}
}

/**
* Utility that creates a matrix client for a virtual user on our homeserver with the specified loclapart.
* @param localPart The localpart of the virtual user we need a client for.
Expand Down Expand Up @@ -246,6 +255,13 @@ export class ManagedMjolnir {
await this.mjolnir.start();
}

/**
* Stop Mjolnir from syncing and processing commands.
*/
public stop() {
this.mjolnir.stop();
}

public async getUserId(): Promise<string> {
return await this.mjolnir.client.getUserId();
}
Expand Down
11 changes: 9 additions & 2 deletions src/commands/CommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { execSinceCommand } from "./SinceCommand";
import { execSetupProtectedRoom } from "./SetupDecentralizedReportingCommand";
import { execSuspendCommand } from "./SuspendCommand";
import { execUnsuspendCommand } from "./UnsuspendCommand";
import { execIgnoreCommand, execListIgnoredCommand } from "./IgnoreCommand";

export const COMMAND_PREFIX = "!mjolnir";

Expand Down Expand Up @@ -141,6 +142,10 @@ export async function handleCommand(roomId: string, event: { content: { body: st
return await execSuspendCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "unsuspend" && parts.length > 2) {
return await execUnsuspendCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "ignore") {
return await execIgnoreCommand(roomId, event, mjolnir, parts);
} else if (parts[1] === "ignored") {
return await execListIgnoredCommand(roomId, event, mjolnir, parts);
} else {
// Help menu
const menu =
Expand Down Expand Up @@ -184,8 +189,10 @@ export async function handleCommand(roomId: string, event: { content: { body: st
"!mjolnir shutdown room <room alias/ID> [message] - Uses the bot's account to shut down a room, preventing access to the room on this server\n" +
"!mjolnir powerlevel <user ID> <power level> [room alias/ID] - Sets the power level of the user in the specified room (or all protected rooms)\n" +
"!mjolnir make admin <room alias> [user alias/ID] - Make the specified user or the bot itself admin of the room\n" +
"!mjolnir suspend <user ID> - Suspend the specified user" +
"!mjolnir unsuspend <user ID> - Unsuspend the specified user" +
"!mjolnir suspend <user ID> - Suspend the specified user\n" +
"!mjolnir unsuspend <user ID> - Unsuspend the specified user\n" +
"!mjolnir ignore <user ID/server name> - Add user to list of users/servers that cannot be banned/ACL'd. Note that this does not survive restart.\n" +
"!mjolnir ignored - List currently ignored entities.\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}`;
Expand Down
49 changes: 49 additions & 0 deletions src/commands/IgnoreCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
Copyright 2024 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 { LogLevel, RichReply } from "@vector-im/matrix-bot-sdk";

// !mjolnir ignore <user|server>
export async function execIgnoreCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
const target = parts[2];

await mjolnir.managementRoomOutput.logMessage(
LogLevel.INFO,
"IgnoreCommand",
`Adding ${target} to internal moderator list.`,
);
mjolnir.moderators.addToIgnore(target);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅");
}

// !mjolnir ignored
export async function execListIgnoredCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
let html = "Ignored users:<ul>";
let text = "Ignored users:\n";

for (const name of mjolnir.moderators.listIgnored()) {
html += `<li>${name}</li>`;
text += `* ${name}\n`;
}

html += "</ul>";

const reply = RichReply.createFor(roomId, event, text, html);
reply["msgtype"] = "m.notice";
await mjolnir.client.sendMessage(roomId, reply);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅");
}
8 changes: 8 additions & 0 deletions src/commands/SinceCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,14 @@ async function execSinceCommandAux(
case Action.Ban: {
for (let join of recentJoins) {
try {
if (mjolnir.moderators.checkMembership(join.userId)) {
await mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN,
"SinceCommand",
`Attempting to ban user ${join.userId} but this is a member of the management room, skipping.`,
);
continue;
}
await mjolnir.client.banUser(join.userId, targetRoomId, reason);
results.succeeded.push(join.userId);
} catch (ex) {
Expand Down
13 changes: 13 additions & 0 deletions src/commands/UnbanBanCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ export async function execBanCommand(roomId: string, event: any, mjolnir: Mjolni
const bits = await parseArguments(roomId, event, mjolnir, parts);
if (!bits) return; // error already handled

const matcher = new MatrixGlob(bits.entity);
const moderators = mjolnir.moderators.listAll();
moderators.forEach(async (name) => {
if (matcher.test(name)) {
await mjolnir.managementRoomOutput.logMessage(
LogLevel.ERROR,
"UnbanBanCommand",
`The ban command ${bits.entity} matches user in moderation room ${name}, aborting command.`,
);
return;
}
});

await bits.list!.banEntity(bits.ruleType!, bits.entity, bits.reason);
await mjolnir.client.unstableApis.addReactionToEvent(roomId, event["event_id"], "✅");
}
Expand Down
8 changes: 8 additions & 0 deletions src/protections/BasicFlooding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ export class BasicFlooding extends Protection {
roomId,
);
if (!mjolnir.config.noop) {
if (mjolnir.moderators.checkMembership(event["sender"])) {
mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN,
"BasicFlooding",
`Attempting to ban ${event["sender"]} but this is a member of the management room, aborting.`,
);
return;
}
await mjolnir.client.banUser(event["sender"], roomId, "spam");
} else {
await mjolnir.managementRoomOutput.logMessage(
Expand Down
8 changes: 8 additions & 0 deletions src/protections/FirstMessageIsImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ export class FirstMessageIsImage extends Protection {
`Banning ${event["sender"]} for posting an image as the first thing after joining in ${roomId}.`,
);
if (!mjolnir.config.noop) {
if (mjolnir.moderators.checkMembership(event["sender"])) {
await mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN,
"FirstMessageIsImage",
`Attempting to ban ${event["sender"]} but they are member of moderation room, aborting.`,
);
return;
}
await mjolnir.client.banUser(event["sender"], roomId, "spam");
} else {
await mjolnir.managementRoomOutput.logMessage(
Expand Down
8 changes: 8 additions & 0 deletions src/protections/ProtectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,14 @@ export class ProtectionManager {
if (consequence.name === "alert") {
/* take no additional action, just print the below message to management room */
} else if (consequence.name === "ban") {
if (this.mjolnir.moderators.checkMembership(sender)) {
await this.mjolnir.managementRoomOutput.logMessage(
LogLevel.WARN,
"ProtectionManager",
`Attempting to ban ${sender} but this is a member of management room, skipping.`,
);
continue;
}
await this.mjolnir.client.banUser(sender, roomId, "abuse detected");
} else if (consequence.name === "redact") {
await this.mjolnir.client.redactEvent(roomId, eventId, "abuse detected");
Expand Down
Loading

0 comments on commit 93282da

Please sign in to comment.