Attempt to factor out protected rooms from Mjolnir.

This is useful to the appservice because it means we don't
have to wrap a Mjolnir that is designed to sync.

It's also useful if we later on want to have specific
settings per space.

It's also just a nice seperation between Mjolnir's needs while
syncing via client-server and the behaviour of syncing policy rooms.

 ### Things that have changed

- `ErrorCache` no longer a static class (phew), gets used by `ProtectedRooms`.
- `ManagementRoomOutput` class gets created to handle logging back to the management room.
- Responsibilities for syncing member bans and server ACL are handled by `ProtectedRooms`.
- Responsibilities for watched lists should be moved to `ProtectedRooms` if they haven't been.
- `EventRedactionQueue` is moved to `ProtectedRooms` since this needs to happen after
  member bans.
- ApplyServerAcls moved to `ProtectedRooms`
- ApplyMemberBans move to `ProtectedRooms`
- `logMessage` and `replaceRoomIdsWithPills` moved to `ManagementRoomOutput`.
- `resyncJoinedRooms` has been made a little more clear, though I am concerned about how often it does run because it does seem expensive.

 ### Things to change

just plugging in all the gaps until it "works".
Maybe figuring out the dependency between "verifyPermissions" and protections.
I didn't want to include protections themselves into `ProtectedRooms` since they have a lot
of machinery. Maybe if they are split into their own class that could be delegated to via `ProtectedRooms` it could work.
I think that is it
This commit is contained in:
gnuxie 2022-08-18 11:11:43 +01:00
parent 8bafa16495
commit 927f2bd70f
8 changed files with 721 additions and 634 deletions

View File

@ -23,24 +23,24 @@ const TRIGGER_INTERVALS: { [key: string]: number } = {
}; };
export default class ErrorCache { export default class ErrorCache {
private static roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {}; private roomsToErrors: { [roomId: string]: { [kind: string]: number } } = {};
private constructor() { constructor() {
} }
public static resetError(roomId: string, kind: string) { public resetError(roomId: string, kind: string) {
if (!ErrorCache.roomsToErrors[roomId]) { if (!this.roomsToErrors[roomId]) {
ErrorCache.roomsToErrors[roomId] = {}; this.roomsToErrors[roomId] = {};
} }
ErrorCache.roomsToErrors[roomId][kind] = 0; this.roomsToErrors[roomId][kind] = 0;
} }
public static triggerError(roomId: string, kind: string): boolean { public triggerError(roomId: string, kind: string): boolean {
if (!ErrorCache.roomsToErrors[roomId]) { if (!this.roomsToErrors[roomId]) {
ErrorCache.roomsToErrors[roomId] = {}; this.roomsToErrors[roomId] = {};
} }
const triggers = ErrorCache.roomsToErrors[roomId]; const triggers = this.roomsToErrors[roomId];
if (!triggers[kind]) { if (!triggers[kind]) {
triggers[kind] = 0; triggers[kind] = 0;
} }

115
src/ManagementRoom.ts Normal file
View File

@ -0,0 +1,115 @@
/*
Copyright 2019, 2022 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 { extractRequestError, LogLevel, LogService, MatrixClient, MessageType, Permalinks, TextualMessageEventContent, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config";
import ErrorCache from "./ErrorCache";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { htmlEscape } from "./utils";
const levelToFn = {
[LogLevel.DEBUG.toString()]: LogService.debug,
[LogLevel.INFO.toString()]: LogService.info,
[LogLevel.WARN.toString()]: LogService.warn,
[LogLevel.ERROR.toString()]: LogService.error,
};
export default class ManagementRoomOutput {
constructor(
private readonly managementRoomId: string,
private readonly client: MatrixClient,
private readonly config: IConfig,
) {
}
/*
* Take an arbitrary string and a set of room IDs, and return a
* TextualMessageEventContent whose plaintext component replaces those room
* IDs with their canonical aliases, and whose html component replaces those
* room IDs with their matrix.to room pills.
*
* @param client The matrix client on which to query for room aliases
* @param text An arbitrary string to rewrite with room aliases and pills
* @param roomIds A set of room IDs to find and replace in `text`
* @param msgtype The desired message type of the returned TextualMessageEventContent
* @returns A TextualMessageEventContent with replaced room IDs
*/
private async replaceRoomIdsWithPills(text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
const content: TextualMessageEventContent = {
body: text,
formatted_body: htmlEscape(text),
msgtype: msgtype,
format: "org.matrix.custom.html",
};
const escapeRegex = (v: string): string => {
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const roomId of roomIds) {
let alias = roomId;
try {
alias = (await this.client.getPublishedAlias(roomId)) || roomId;
} catch (e) {
// This is a recursive call, so tell the function not to try and call us
await this.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
LogService.warn("utils", extractRequestError(e));
}
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
content.body = content.body.replace(regexRoomId, alias);
if (content.formatted_body) {
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
}
}
return content;
}
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const client = this.client;
const roomIds = [this.managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = {
body: message,
formatted_body: htmlEscape(message),
msgtype: "m.notice",
format: "org.matrix.custom.html",
};
if (!isRecursive) {
evContent = await this.replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
}
await client.sendMessage(this.managementRoomId, evContent);
}
levelToFn[level.toString()](module, message);
}
}

View File

@ -43,20 +43,14 @@ import { htmlEscape } from "./utils";
import { ReportManager } from "./report/ReportManager"; import { ReportManager } from "./report/ReportManager";
import { ReportPoller } from "./report/ReportPoller"; import { ReportPoller } from "./report/ReportPoller";
import { WebAPIs } from "./webapis/WebAPIs"; import { WebAPIs } from "./webapis/WebAPIs";
import { replaceRoomIdsWithPills } from "./utils";
import RuleServer from "./models/RuleServer"; import RuleServer from "./models/RuleServer";
import { RoomMemberManager } from "./RoomMembers"; import { RoomMemberManager } from "./RoomMembers";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker"; import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { ThrottlingQueue } from "./queues/ThrottlingQueue"; import { ThrottlingQueue } from "./queues/ThrottlingQueue";
import { IConfig } from "./config"; import { IConfig } from "./config";
import PolicyList, { ListRuleChange } from "./models/PolicyList"; import PolicyList, { ListRuleChange } from "./models/PolicyList";
import { ProtectedRooms } from "./ProtectedRooms";
const levelToFn = { import ManagementRoomOutput from "./ManagementRoom";
[LogLevel.DEBUG.toString()]: LogService.debug,
[LogLevel.INFO.toString()]: LogService.info,
[LogLevel.WARN.toString()]: LogService.warn,
[LogLevel.ERROR.toString()]: LogService.error,
};
export const STATE_NOT_STARTED = "not_started"; export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions"; export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@ -85,33 +79,19 @@ export class Mjolnir {
* but have been flagged by the automatic spam detection as suispicous * but have been flagged by the automatic spam detection as suispicous
*/ */
private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue(); private unlistedUserRedactionQueue = new UnlistedUserRedactionQueue();
/**
* This is a queue for redactions to process after mjolnir
* has finished applying ACL and bans when syncing.
*/
private eventRedactionQueue = new EventRedactionQueue();
private automaticRedactionReasons: MatrixGlob[] = [];
/** /**
* Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`. * Every room that we are joined to except the management room. Used to implement `config.protectAllJoinedRooms`.
*/ */
private protectedJoinedRoomIds: string[] = []; private protectedJoinedRoomIds: string[] = [];
private protectedRoomsTracker: ProtectedRooms;
/** /**
* These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`. * These are rooms that were explicitly said to be protected either in the config, or by what is present in the account data for `org.matrix.mjolnir.protected_rooms`.
*/ */
private explicitlyProtectedRoomIds: string[] = []; private explicitlyProtectedRoomIds: string[] = [];
private unprotectedWatchedListRooms: string[] = []; private unprotectedWatchedListRooms: string[] = [];
private webapis: WebAPIs; private webapis: WebAPIs;
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
public taskQueue: ThrottlingQueue; public taskQueue: ThrottlingQueue;
/** private managementRoom: ManagementRoomOutput;
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
* This is because requests operating with rules from an older version of the list that are slow
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
* which would cause several requests to a room to send a new m.room.server_acl event.
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
*/
public aclChain: Promise<void> = Promise.resolve();
/* /*
* Config-enabled polling of reports in Synapse, so Mjolnir can react to reports * Config-enabled polling of reports in Synapse, so Mjolnir can react to reports
*/ */
@ -278,9 +258,6 @@ export class Mjolnir {
} }
}); });
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
// Setup Web APIs // Setup Web APIs
console.log("Creating Web APIs"); console.log("Creating Web APIs");
const reportManager = new ReportManager(this); const reportManager = new ReportManager(this);
@ -315,10 +292,6 @@ export class Mjolnir {
return this.unlistedUserRedactionQueue; return this.unlistedUserRedactionQueue;
} }
public get automaticRedactGlobs(): MatrixGlob[] {
return this.automaticRedactionReasons;
}
/** /**
* Start Mjölnir. * Start Mjölnir.
*/ */
@ -339,7 +312,7 @@ export class Mjolnir {
if (err.body?.errcode !== "M_NOT_FOUND") { if (err.body?.errcode !== "M_NOT_FOUND") {
throw err; throw err;
} else { } else {
this.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet"); this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "report poll setting does not exist yet");
} }
} }
this.reportPoller.start(reportPollSetting.from); this.reportPoller.start(reportPollSetting.from);
@ -348,7 +321,7 @@ export class Mjolnir {
// Load the state. // Load the state.
this.currentState = STATE_CHECKING_PERMISSIONS; this.currentState = STATE_CHECKING_PERMISSIONS;
await this.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms..."); await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
await this.resyncJoinedRooms(false); await this.resyncJoinedRooms(false);
try { try {
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE); const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
@ -366,25 +339,25 @@ export class Mjolnir {
this.applyUnprotectedRooms(); this.applyUnprotectedRooms();
if (this.config.verifyPermissionsOnStartup) { if (this.config.verifyPermissionsOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions..."); await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(this.config.verboseLogging); await this.verifyPermissions(this.config.verboseLogging);
} }
this.currentState = STATE_SYNCING; this.currentState = STATE_SYNCING;
if (this.config.syncOnStartup) { if (this.config.syncOnStartup) {
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists..."); await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(this.config.verboseLogging); await this.syncLists(this.config.verboseLogging);
await this.registerProtections(); await this.registerProtections();
} }
this.currentState = STATE_RUNNING; this.currentState = STATE_RUNNING;
await this.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms."); await this.managementRoom.logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
} catch (err) { } catch (err) {
try { try {
LogService.error("Mjolnir", "Error during startup:"); LogService.error("Mjolnir", "Error during startup:");
LogService.error("Mjolnir", extractRequestError(err)); LogService.error("Mjolnir", extractRequestError(err));
this.stop(); this.stop();
await this.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console"); await this.managementRoom.logMessage(LogLevel.ERROR, "Mjolnir@startup", "Startup failed due to error - see console");
throw err; throw err;
} catch (e) { } catch (e) {
LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`); LogService.error("Mjolnir", `Failed to report startup error to the management room: ${e}`);
@ -403,39 +376,9 @@ export class Mjolnir {
this.reportPoller?.stop(); this.reportPoller?.stop();
} }
public async logMessage(level: LogLevel, module: string, message: string | any, additionalRoomIds: string[] | string | null = null, isRecursive = false): Promise<any> {
if (!additionalRoomIds) additionalRoomIds = [];
if (!Array.isArray(additionalRoomIds)) additionalRoomIds = [additionalRoomIds];
if (this.config.verboseLogging || LogLevel.INFO.includes(level)) {
let clientMessage = message;
if (level === LogLevel.WARN) clientMessage = `⚠ | ${message}`;
if (level === LogLevel.ERROR) clientMessage = `‼ | ${message}`;
const client = this.client;
const roomIds = [this.managementRoomId, ...additionalRoomIds];
let evContent: TextualMessageEventContent = {
body: message,
formatted_body: htmlEscape(message),
msgtype: "m.notice",
format: "org.matrix.custom.html",
};
if (!isRecursive) {
evContent = await replaceRoomIdsWithPills(this, clientMessage, new Set(roomIds), "m.notice");
}
await client.sendMessage(this.managementRoomId, evContent);
}
levelToFn[level.toString()](module, message);
}
public async addProtectedRoom(roomId: string) { public async addProtectedRoom(roomId: string) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId); this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.roomJoins.addRoom(roomId); this.roomJoins.addRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId); const unprotectedIdx = this.unprotectedWatchedListRooms.indexOf(roomId);
if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1); if (unprotectedIdx >= 0) this.unprotectedWatchedListRooms.splice(unprotectedIdx, 1);
@ -450,13 +393,11 @@ export class Mjolnir {
const rooms = (additionalProtectedRooms?.rooms ?? []); const rooms = (additionalProtectedRooms?.rooms ?? []);
rooms.push(roomId); rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms }); await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
await this.syncLists(this.config.verboseLogging);
} }
public async removeProtectedRoom(roomId: string) { public async removeProtectedRoom(roomId: string) {
delete this.protectedRooms[roomId]; delete this.protectedRooms[roomId];
this.roomJoins.removeRoom(roomId); this.roomJoins.removeRoom(roomId);
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId); const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1); if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
@ -471,27 +412,33 @@ export class Mjolnir {
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms); await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
} }
// need to brewritten to add/remove from a ProtectedRooms instance.
private async resyncJoinedRooms(withSync = true) { private async resyncJoinedRooms(withSync = true) {
// this is really terrible!
// what the fuck does it do???
// just fix it bloody hell mate.
if (!this.config.protectAllJoinedRooms) return; if (!this.config.protectAllJoinedRooms) return;
const joinedRoomIds = (await this.client.getJoinedRooms()).filter(r => r !== this.managementRoomId); const joinedRoomIds = (await this.client.getJoinedRooms())
.filter(r => r !== this.managementRoomId && !this.unprotectedWatchedListRooms.includes(r));
const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds); const oldRoomIdsSet = new Set(this.protectedJoinedRoomIds);
const joinedRoomIdsSet = new Set(joinedRoomIds); const joinedRoomIdsSet = new Set(joinedRoomIds);
// Remove every room id that we have joined from `this.protectedRooms`. // find every room that we have left (since last time)
for (const roomId of this.protectedJoinedRoomIds) { for (const roomId of oldRoomIdsSet.keys()) {
delete this.protectedRooms[roomId];
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
if (!joinedRoomIdsSet.has(roomId)) { if (!joinedRoomIdsSet.has(roomId)) {
// Then we have left this room.
delete this.protectedRooms[roomId];
this.protectedRoomsTracker.removeProtectedRoom(roomId);
this.roomJoins.removeRoom(roomId); this.roomJoins.removeRoom(roomId);
} }
} }
this.protectedJoinedRoomIds = joinedRoomIds; // find every room that we have joined (since last time).
// Add all joined rooms back to the permalink object for (const roomId of joinedRoomIdsSet.keys()) {
for (const roomId of joinedRoomIds) {
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
if (!oldRoomIdsSet.has(roomId)) { if (!oldRoomIdsSet.has(roomId)) {
// Then we have joined this room
this.roomJoins.addRoom(roomId); this.roomJoins.addRoom(roomId);
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
await this.protectedRoomsTracker.addProtectedRoom(roomId);
} }
} }
@ -580,7 +527,7 @@ export class Mjolnir {
) { ) {
validatedSettings[key] = value; validatedSettings[key] = value;
} else { } else {
await this.logMessage( await this.managementRoom.logMessage(
LogLevel.WARN, LogLevel.WARN,
"getProtectionSetting", "getProtectionSetting",
`Tried to read ${protectionName}.${key} and got invalid value ${value}` `Tried to read ${protectionName}.${key} and got invalid value ${value}`
@ -741,7 +688,7 @@ export class Mjolnir {
// Ignore - probably haven't warned about it yet // Ignore - probably haven't warned about it yet
} }
await this.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId); await this.managementRoom.logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true }); await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
} }
@ -777,188 +724,12 @@ export class Mjolnir {
} }
} }
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
for (const roomId of Object.keys(this.protectedRooms)) {
errors.push(...(await this.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.client.getUserId();
const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
if (!powerLevels) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Missing power levels state event");
}
function plDefault(val: number | undefined | null, def: number): number {
if (!val && val !== 0) return def;
return val;
}
const users = powerLevels['users'] || {};
const events = powerLevels['events'] || {};
const usersDefault = plDefault(powerLevels['users_default'], 0);
const stateDefault = plDefault(powerLevels['state_default'], 50);
const ban = plDefault(powerLevels['ban'], 50);
const kick = plDefault(powerLevels['kick'], 50);
const redact = plDefault(powerLevels['redact'], 50);
const userLevel = plDefault(users[ownUserId], usersDefault);
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
// Wants: ban, kick, redact, m.room.server_acl
if (userLevel < ban) {
errors.push({
roomId,
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < kick) {
errors.push({
roomId,
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < redact) {
errors.push({
roomId,
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < aclLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
// Wants: Additional permissions
for (const additionalPermission of additionalPermissions) {
const permLevel = plDefault(events[additionalPermission], stateDefault);
if (userLevel < permLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
}
// Otherwise OK
} catch (e) {
LogService.error("Mjolnir", extractRequestError(e));
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
}
return errors;
}
private requiredProtectionPermissions(): Set<string> { private requiredProtectionPermissions(): Set<string> {
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat()) return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
} }
/**
* @returns The protected rooms ordered by the most recently active first.
*/
public protectedRoomsByActivity(): string[] {
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
}
/**
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
* @param verbose Whether to report any errors to the management room.
*/
public async syncLists(verbose = true) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
const changes = await policyList.updateList();
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
applyServerAcls(this.policyLists, this.protectedRoomsByActivity(), this),
applyUserBans(this.policyLists, this.protectedRoomsByActivity(), this)
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors) {
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
await this.printBanlistChanges(changes, policyList, true);
}
private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) { private async handleConsequence(protection: Protection, roomId: string, eventId: string, sender: string, consequence: Consequence) {
switch (consequence.type) { switch (consequence.type) {
case ConsequenceType.alert: case ConsequenceType.alert:
@ -1039,111 +810,8 @@ export class Mjolnir {
// can flag the event for redaction. // can flag the event for redaction.
await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this); await this.unlistedUserRedactionHandler.handleEvent(roomId, event, this);
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
ErrorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
} }
return;
} else if (event['type'] === "m.room.member") {
// The reason we have to apply bans on each member change is because
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await applyUserBans(this.policyLists, [roomId], this);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
}
}
}
/**
* Print the changes to a banlist to the management room.
* @param changes A list of changes that have been made to a particular ban list.
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);
}
if (changes.length <= 0) return false;
let html = "";
let text = "";
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
for (const change of changes) {
const rule = change.rule;
let ruleKind: string = rule.kind;
if (ruleKind === RULE_USER) {
ruleKind = 'user';
} else if (ruleKind === RULE_SERVER) {
ruleKind = 'server';
} else if (ruleKind === RULE_ROOM) {
ruleKind = 'room';
}
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
if (!logAnyways) {
errors = errors.filter(e => ErrorCache.triggerError(e.roomId, e.errorKind));
if (errors.length <= 0) {
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
return true;
}
}
let html = "";
let text = "";
const htmlTitle = title ? `${title}<br />` : '';
const textTitle = title ? `${title}\n` : '';
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const error of errors) {
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
const url = Permalinks.forRoom(alias, viaServers);
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
}
html += "</ul>";
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
} }
public async isSynapseAdmin(): Promise<boolean> { public async isSynapseAdmin(): Promise<boolean> {
@ -1189,22 +857,6 @@ export class Mjolnir {
} }
} }
public queueRedactUserMessagesIn(userId: string, roomId: string) {
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
}
/**
* Process all queued redactions, this is usually called at the end of the sync process,
* after all users have been banned and ACLs applied.
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
* We then carry on processing the next redactions.
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
* @returns The list of errors encountered, for reporting to the management room.
*/
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
return await this.eventRedactionQueue.process(this, roomId);
}
private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) { private async handleReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
for (const protection of this.enabledProtections) { for (const protection of this.enabledProtections) {
await protection.handleReport(this, roomId, reporterId, event, reason); await protection.handleReport(this, roomId, reporterId, event, reason);

551
src/ProtectedRooms.ts Normal file
View File

@ -0,0 +1,551 @@
/*
Copyright 2019, 2022 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 { extractRequestError, LogLevel, LogService, MatrixClient, MatrixGlob, Permalinks, UserID } from "matrix-bot-sdk";
import { IConfig } from "./config";
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
import ManagementRoomOutput from "./ManagementRoom";
import { RULE_ROOM, RULE_SERVER, RULE_USER } from "./models/ListRule";
import PolicyList, { ListRuleChange } from "./models/PolicyList";
import { RoomUpdateError } from "./models/RoomUpdateError";
import { ServerAcl } from "./models/ServerAcl";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import { ProtectedRoomActivityTracker } from "./queues/ProtectedRoomActivityTracker";
import { htmlEscape } from "./utils";
/**
* When you consider spaces https://github.com/matrix-org/mjolnir/issues/283
* rather than indexing rooms via some collection, you instead have rooms
* and then you find out which lists apply to them.
* This is important because right now we have a collection of rooms
* and implicitly a bunch of lists.
*
* It's important not to tie this to the one group of rooms that a mjolnir may watch too much
* as in future we might want to borrow this class to represent a space.
*/
export class ProtectedRooms {
private protectedRooms = new Set</* room id */string>();
private policyLists: PolicyList[];
private protectedRoomActivityTracker: ProtectedRoomActivityTracker;
/**
* This is a queue for redactions to process after mjolnir
* has finished applying ACL and bans when syncing.
*/
private readonly eventRedactionQueue = new EventRedactionQueue();
private readonly errorCache = new ErrorCache();
private automaticRedactionReasons: MatrixGlob[] = [];
/**
* Used to provide mutual exclusion when synchronizing rooms with the state of a policy list.
* This is because requests operating with rules from an older version of the list that are slow
* could race & give the room an inconsistent state. An example is if we add multiple m.policy.rule.server rules,
* which would cause several requests to a room to send a new m.room.server_acl event.
* These requests could finish in any order, which has left rooms with an inconsistent server_acl event
* until Mjolnir synchronises the room with its policy lists again, which can be in the region of hours.
*/
public aclChain: Promise<void> = Promise.resolve();
constructor(
private readonly client: MatrixClient,
private readonly clientUserId: string,
private readonly managementRoomId: string,
private readonly managementRoom: ManagementRoomOutput,
private readonly config: IConfig,
) {
// Setup room activity watcher
this.protectedRoomActivityTracker = new ProtectedRoomActivityTracker(client);
}
public queueRedactUserMessagesIn(userId: string, roomId: string) {
this.eventRedactionQueue.add(new RedactUserInRoom(userId, roomId));
}
public get automaticRedactGlobs(): MatrixGlob[] {
return this.automaticRedactionReasons;
}
/**
* Process all queued redactions, this is usually called at the end of the sync process,
* after all users have been banned and ACLs applied.
* If a redaction cannot be processed, the redaction is skipped and removed from the queue.
* We then carry on processing the next redactions.
* @param roomId Limit processing to one room only, otherwise process redactions for all rooms.
* @returns The list of errors encountered, for reporting to the management room.
*/
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
return await this.eventRedactionQueue.process(this.client, this.managementRoom, roomId);
}
/**
* @returns The protected rooms ordered by the most recently active first.
*/
public protectedRoomsByActivity(): string[] {
return this.protectedRoomActivityTracker.protectedRoomsByActivity();
}
public async handleEvent(roomId: string, event: any) {
if (event['sender'] === this.clientUserId) {
throw new TypeError("`ProtectedRooms::handleEvent` should not be used to inform about events sent by mjolnir.");
}
if (event['type'] === 'm.room.power_levels' && event['state_key'] === '') {
// power levels were updated - recheck permissions
this.errorCache.resetError(roomId, ERROR_KIND_PERMISSION);
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Power levels changed in ${roomId} - checking permissions...`, roomId);
const errors = await this.verifyPermissionsIn(roomId);
const hadErrors = await this.printActionResult(errors);
if (!hadErrors) {
await this.managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `All permissions look OK.`);
}
return;
} else if (event['type'] === "m.room.member") {
// The reason we have to apply bans on each member change is because
// we cannot eagerly ban users (that is to ban them when they have never been a member)
// as they can be force joined to a room they might not have known existed.
// Only apply bans and then redactions in the room we are currently looking at.
const banErrors = await this.applyUserBans(this.policyLists, [roomId]);
const redactionErrors = await this.processRedactionQueue(roomId);
await this.printActionResult(banErrors);
await this.printActionResult(redactionErrors);
}
}
/**
* Sync all the rooms with all the watched lists, banning and applying any changed ACLS.
* @param verbose Whether to report any errors to the management room.
*/
public async syncLists(verbose = true) {
for (const list of this.policyLists) {
const changes = await list.updateList();
await this.printBanlistChanges(changes, list, true);
}
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">Done updating rooms - no errors</font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
public async addProtectedRoom(roomId: string): Promise<void> {
if (this.protectedRooms.has(roomId)) {
// we need to protect ourselves form syncing all the lists unnecessarily
// as Mjolnir does call this method repeatedly.
return;
}
this.protectedRooms.add(roomId);
this.protectedRoomActivityTracker.addProtectedRoom(roomId);
await this.syncLists(this.config.verboseLogging);
}
public removeProtectedRoom(roomId: string): void {
this.protectedRoomActivityTracker.removeProtectedRoom(roomId);
this.protectedRooms.delete(roomId);
}
/**
* Pulls any changes to the rules that are in a policy room and updates all protected rooms
* with those changes. Does not fail if there are errors updating the room, these are reported to the management room.
* @param policyList The `PolicyList` which we will check for changes and apply them to all protected rooms.
* @returns When all of the protected rooms have been updated.
*/
private async syncWithPolicyList(policyList: PolicyList): Promise<void> {
// this bit can move away into a listener.
const changes = await policyList.updateList();
let hadErrors = false;
const [aclErrors, banErrors] = await Promise.all([
this.applyServerAcls(this.policyLists, this.protectedRoomsByActivity()),
this.applyUserBans(this.policyLists, this.protectedRoomsByActivity())
]);
const redactionErrors = await this.processRedactionQueue();
hadErrors = hadErrors || await this.printActionResult(aclErrors, "Errors updating server ACLs:");
hadErrors = hadErrors || await this.printActionResult(banErrors, "Errors updating member bans:");
hadErrors = hadErrors || await this.printActionResult(redactionErrors, "Error updating redactions:");
if (!hadErrors) {
const html = `<font color="#00cc00"><b>Done updating rooms - no errors</b></font>`;
const text = "Done updating rooms - no errors";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
// This can fail if the change is very large and it is much less important than applying bans, so do it last.
await this.printBanlistChanges(changes, policyList, true);
}
/**
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL.
* @param {PolicyList[]} lists The lists to construct ACLs from.
* @param {string[]} roomIds The room IDs to apply the ACLs in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/
private async applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
// finish out of order and therefore leave the room out of sync with the policy lists.
return new Promise((resolve, reject) => {
this.aclChain = this.aclChain
.then(() => this._applyServerAcls(lists, roomIds))
.then(resolve, reject);
});
}
private async _applyServerAcls(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
const serverName: string = new UserID(await this.client.getUserId()).domain;
// Construct a server ACL first
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (const list of lists) {
for (const rule of list.serverRules) {
acl.denyServer(rule.entity);
}
}
const finalAcl = acl.safeAclContent();
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
this.managementRoom.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
}
if (this.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML
await this.client.sendNotice(this.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
try {
const currentAcl = await this.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
if (acl.matches(currentAcl)) {
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
continue;
}
} catch (e) {
// ignore - assume no ACL
}
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
if (!this.config.noop) {
await this.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
} else {
await this.managementRoom.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({ roomId, errorMessage: message, errorKind: kind });
}
}
return errors;
}
/**
* Applies the member bans represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* @param {PolicyList[]} lists The lists to determine bans from.
* @param {string[]} roomIds The room IDs to apply the bans in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/
private async applyUserBans(lists: PolicyList[], roomIds: string[]): Promise<RoomUpdateError[]> {
// We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoom.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
let members: { userId: string, membership: string }[];
if (this.config.fasterMembershipChecks) {
const memberIds = await this.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => {
return { userId: u, membership: "join" };
});
} else {
const state = await this.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
});
}
for (const member of members) {
if (member.membership === 'ban') {
continue; // user already banned
}
let banned = false;
for (const list of lists) {
for (const userRule of list.userRules) {
if (userRule.isMatch(member.userId)) {
// User needs to be banned
// We specifically use sendNotice to avoid having to escape HTML
await this.managementRoom.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
if (!this.config.noop) {
await this.client.banUser(member.userId, roomId, userRule.reason);
if (this.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
this.queueRedactUserMessagesIn(member.userId, roomId);
}
} else {
await this.managementRoom.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
banned = true;
break;
}
}
if (banned) break;
}
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push({
roomId,
errorMessage: message,
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
});
}
}
return errors;
}
/**
* Print the changes to a banlist to the management room.
* @param changes A list of changes that have been made to a particular ban list.
* @param ignoreSelf Whether to exclude changes that have been made by Mjolnir.
* @returns true if the message was sent, false if it wasn't (because there there were no changes to report).
*/
private async printBanlistChanges(changes: ListRuleChange[], list: PolicyList, ignoreSelf = false): Promise<boolean> {
if (ignoreSelf) {
const sender = await this.client.getUserId();
changes = changes.filter(change => change.sender !== sender);
}
if (changes.length <= 0) return false;
let html = "";
let text = "";
const changesInfo = `updated with ${changes.length} ` + (changes.length === 1 ? 'change:' : 'changes:');
const shortcodeInfo = list.listShortcode ? ` (shortcode: ${htmlEscape(list.listShortcode)})` : '';
html += `<a href="${htmlEscape(list.roomRef)}">${htmlEscape(list.roomId)}</a>${shortcodeInfo} ${changesInfo}<br/><ul>`;
text += `${list.roomRef}${shortcodeInfo} ${changesInfo}:\n`;
for (const change of changes) {
const rule = change.rule;
let ruleKind: string = rule.kind;
if (ruleKind === RULE_USER) {
ruleKind = 'user';
} else if (ruleKind === RULE_SERVER) {
ruleKind = 'server';
} else if (ruleKind === RULE_ROOM) {
ruleKind = 'room';
}
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
}
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
if (!logAnyways) {
errors = errors.filter(e => this.errorCache.triggerError(e.roomId, e.errorKind));
if (errors.length <= 0) {
LogService.warn("Mjolnir", "Multiple errors are happening, however they are muted. Please check the management room.");
return true;
}
}
let html = "";
let text = "";
const htmlTitle = title ? `${title}<br />` : '';
const textTitle = title ? `${title}\n` : '';
html += `<font color="#ff0000"><b>${htmlTitle}${errors.length} errors updating protected rooms!</b></font><br /><ul>`;
text += `${textTitle}${errors.length} errors updating protected rooms!\n`;
const viaServers = [(new UserID(await this.client.getUserId())).domain];
for (const error of errors) {
const alias = (await this.client.getPublishedAlias(error.roomId)) || error.roomId;
const url = Permalinks.forRoom(alias, viaServers);
html += `<li><a href="${url}">${alias}</a> - ${error.errorMessage}</li>`;
text += `${url} - ${error.errorMessage}\n`;
}
html += "</ul>";
const message = {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
};
await this.client.sendMessage(this.managementRoomId, message);
return true;
}
public requiredProtectionPermissions() {
throw new TypeError("Unimplemented, need to put protections into here too.")
}
public async verifyPermissions(verbose = true, printRegardless = false) {
const errors: RoomUpdateError[] = [];
for (const roomId of Object.keys(this.protectedRooms)) {
errors.push(...(await this.verifyPermissionsIn(roomId)));
}
const hadErrors = await this.printActionResult(errors, "Permission errors in protected rooms:", printRegardless);
if (!hadErrors && verbose) {
const html = `<font color="#00cc00">All permissions look OK.</font>`;
const text = "All permissions look OK.";
await this.client.sendMessage(this.managementRoomId, {
msgtype: "m.notice",
body: text,
format: "org.matrix.custom.html",
formatted_body: html,
});
}
}
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.client.getUserId();
const powerLevels = await this.client.getRoomStateEvent(roomId, "m.room.power_levels", "");
if (!powerLevels) {
// noinspection ExceptionCaughtLocallyJS
throw new Error("Missing power levels state event");
}
function plDefault(val: number | undefined | null, def: number): number {
if (!val && val !== 0) return def;
return val;
}
const users = powerLevels['users'] || {};
const events = powerLevels['events'] || {};
const usersDefault = plDefault(powerLevels['users_default'], 0);
const stateDefault = plDefault(powerLevels['state_default'], 50);
const ban = plDefault(powerLevels['ban'], 50);
const kick = plDefault(powerLevels['kick'], 50);
const redact = plDefault(powerLevels['redact'], 50);
const userLevel = plDefault(users[ownUserId], usersDefault);
const aclLevel = plDefault(events["m.room.server_acl"], stateDefault);
// Wants: ban, kick, redact, m.room.server_acl
if (userLevel < ban) {
errors.push({
roomId,
errorMessage: `Missing power level for bans: ${userLevel} < ${ban}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < kick) {
errors.push({
roomId,
errorMessage: `Missing power level for kicks: ${userLevel} < ${kick}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < redact) {
errors.push({
roomId,
errorMessage: `Missing power level for redactions: ${userLevel} < ${redact}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
if (userLevel < aclLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for server ACLs: ${userLevel} < ${aclLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
// Wants: Additional permissions
for (const additionalPermission of additionalPermissions) {
const permLevel = plDefault(events[additionalPermission], stateDefault);
if (userLevel < permLevel) {
errors.push({
roomId,
errorMessage: `Missing power level for "${additionalPermission}" state events: ${userLevel} < ${permLevel}`,
errorKind: ERROR_KIND_PERMISSION,
});
}
}
// Otherwise OK
} catch (e) {
LogService.error("Mjolnir", extractRequestError(e));
errors.push({
roomId,
errorMessage: e.message || (e.body ? e.body.error : '<no message>'),
errorKind: ERROR_KIND_FATAL,
});
}
return errors;
}
}

View File

@ -1,95 +0,0 @@
/*
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
import { ServerAcl } from "../models/ServerAcl";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, UserID } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/**
* Applies the server ACLs represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* Does not update the banLists before taking their rules to build the server ACL.
* @param {PolicyList[]} lists The lists to construct ACLs from.
* @param {string[]} roomIds The room IDs to apply the ACLs in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
*/
export async function applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
// we need to provide mutual exclusion so that we do not have requests updating the m.room.server_acl event
// finish out of order and therefore leave the room out of sync with the policy lists.
return new Promise((resolve, reject) => {
mjolnir.aclChain = mjolnir.aclChain
.then(() => _applyServerAcls(lists, roomIds, mjolnir))
.then(resolve, reject);
});
}
async function _applyServerAcls(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
const serverName: string = new UserID(await mjolnir.client.getUserId()).domain;
// Construct a server ACL first
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
for (const list of lists) {
for (const rule of list.serverRules) {
acl.denyServer(rule.entity);
}
}
const finalAcl = acl.safeAclContent();
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
}
if (mjolnir.config.verboseLogging) {
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
}
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Checking ACLs for ${roomId}`, roomId);
try {
const currentAcl = await mjolnir.client.getRoomStateEvent(roomId, "m.room.server_acl", "");
if (acl.matches(currentAcl)) {
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Skipping ACLs for ${roomId} because they are already the right ones`, roomId);
continue;
}
} catch (e) {
// ignore - assume no ACL
}
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyAcl", `Applying ACL in ${roomId}`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.server_acl", "", finalAcl);
} else {
await mjolnir.logMessage(LogLevel.WARN, "ApplyAcl", `Tried to apply ACL in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
const kind = message && message.includes("You don't have permission to post that to the room") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL;
errors.push({ roomId, errorMessage: message, errorKind: kind });
}
}
return errors;
}

View File

@ -1,93 +0,0 @@
/*
Copyright 2019, 2020 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 PolicyList from "../models/PolicyList";
import { RoomUpdateError } from "../models/RoomUpdateError";
import { Mjolnir } from "../Mjolnir";
import { LogLevel } from "matrix-bot-sdk";
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
/**
* Applies the member bans represented by the ban lists to the provided rooms, returning the
* room IDs that could not be updated and their error.
* @param {PolicyList[]} lists The lists to determine bans from.
* @param {string[]} roomIds The room IDs to apply the bans in.
* @param {Mjolnir} mjolnir The Mjolnir client to apply the bans with.
*/
export async function applyUserBans(lists: PolicyList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
// We can only ban people who are not already banned, and who match the rules.
const errors: RoomUpdateError[] = [];
for (const roomId of roomIds) {
try {
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.DEBUG, "ApplyBan", `Updating member bans in ${roomId}`, roomId);
let members: { userId: string, membership: string }[];
if (mjolnir.config.fasterMembershipChecks) {
const memberIds = await mjolnir.client.getJoinedRoomMembers(roomId);
members = memberIds.map(u => {
return { userId: u, membership: "join" };
});
} else {
const state = await mjolnir.client.getRoomState(roomId);
members = state.filter(s => s['type'] === 'm.room.member' && !!s['state_key']).map(s => {
return { userId: s['state_key'], membership: s['content'] ? s['content']['membership'] : 'leave' };
});
}
for (const member of members) {
if (member.membership === 'ban') {
continue; // user already banned
}
let banned = false;
for (const list of lists) {
for (const userRule of list.userRules) {
if (userRule.isMatch(member.userId)) {
// User needs to be banned
// We specifically use sendNotice to avoid having to escape HTML
await mjolnir.logMessage(LogLevel.INFO, "ApplyBan", `Banning ${member.userId} in ${roomId} for: ${userRule.reason}`, roomId);
if (!mjolnir.config.noop) {
await mjolnir.client.banUser(member.userId, roomId, userRule.reason);
if (mjolnir.automaticRedactGlobs.find(g => g.test(userRule.reason.toLowerCase()))) {
mjolnir.queueRedactUserMessagesIn(member.userId, roomId);
}
} else {
await mjolnir.logMessage(LogLevel.WARN, "ApplyBan", `Tried to ban ${member.userId} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
banned = true;
break;
}
}
if (banned) break;
}
}
} catch (e) {
const message = e.message || (e.body ? e.body.error : '<no message>');
errors.push({
roomId,
errorMessage: message,
errorKind: message && message.includes("You don't have permission to ban") ? ERROR_KIND_PERMISSION : ERROR_KIND_FATAL,
});
}
}
return errors;
}

View File

@ -13,11 +13,12 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { LogLevel } from "matrix-bot-sdk" import { LogLevel, MatrixClient } from "matrix-bot-sdk"
import { ERROR_KIND_FATAL } from "../ErrorCache"; import { ERROR_KIND_FATAL } from "../ErrorCache";
import { RoomUpdateError } from "../models/RoomUpdateError"; import { RoomUpdateError } from "../models/RoomUpdateError";
import { redactUserMessagesIn } from "../utils"; import { redactUserMessagesIn } from "../utils";
import { Mjolnir } from "../Mjolnir"; import { Mjolnir } from "../Mjolnir";
import ManagementRoomOutput from "../ManagementRoom";
export interface QueuedRedaction { export interface QueuedRedaction {
/** The room which the redaction will take place in. */ /** The room which the redaction will take place in. */
@ -27,7 +28,7 @@ export interface QueuedRedaction {
* Called by the EventRedactionQueue. * Called by the EventRedactionQueue.
* @param client A MatrixClient to use to carry out the redaction. * @param client A MatrixClient to use to carry out the redaction.
*/ */
redact(mjolnir: Mjolnir): Promise<void> redact(client: MatrixClient, managementRoom: ManagementRoomOutput): Promise<void>
/** /**
* Used to test whether the redaction is the equivalent to another redaction. * Used to test whether the redaction is the equivalent to another redaction.
* @param redaction Another QueuedRedaction to test if this redaction is an equivalent to. * @param redaction Another QueuedRedaction to test if this redaction is an equivalent to.
@ -47,9 +48,9 @@ export class RedactUserInRoom implements QueuedRedaction {
this.roomId = roomId; this.roomId = roomId;
} }
public async redact(mjolnir: Mjolnir) { public async redact(client: MatrixClient, managementRoom: ManagementRoomOutput) {
await mjolnir.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`); await managementRoom.logMessage(LogLevel.DEBUG, "Mjolnir", `Redacting events from ${this.userId} in room ${this.roomId}.`);
await redactUserMessagesIn(mjolnir, this.userId, [this.roomId]); await redactUserMessagesIn(client, managementRoom, this.userId, [this.roomId]);
} }
public redactionEqual(redaction: QueuedRedaction): boolean { public redactionEqual(redaction: QueuedRedaction): boolean {
@ -107,12 +108,12 @@ export class EventRedactionQueue {
* @param limitToRoomId If the roomId is provided, only redactions for that room will be processed. * @param limitToRoomId If the roomId is provided, only redactions for that room will be processed.
* @returns A description of any errors encountered by each QueuedRedaction that was processed. * @returns A description of any errors encountered by each QueuedRedaction that was processed.
*/ */
public async process(mjolnir: Mjolnir, limitToRoomId?: string): Promise<RoomUpdateError[]> { public async process(client: MatrixClient, managementRoom: ManagementRoomOutput, limitToRoomId?: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = []; const errors: RoomUpdateError[] = [];
const redact = async (currentBatch: QueuedRedaction[]) => { const redact = async (currentBatch: QueuedRedaction[]) => {
for (const redaction of currentBatch) { for (const redaction of currentBatch) {
try { try {
await redaction.redact(mjolnir); await redaction.redact(client, managementRoom);
} catch (e) { } catch (e) {
let roomError: RoomUpdateError; let roomError: RoomUpdateError;
if (e.roomId && e.errorMessage && e.errorKind) { if (e.roomId && e.errorMessage && e.errorKind) {

View File

@ -30,6 +30,7 @@ import {
import { Mjolnir } from "./Mjolnir"; import { Mjolnir } from "./Mjolnir";
import { ClientRequest, IncomingMessage } from "http"; import { ClientRequest, IncomingMessage } from "http";
import { default as parseDuration } from "parse-duration"; import { default as parseDuration } from "parse-duration";
import ManagementRoomOutput from "./ManagementRoom";
// Define a few aliases to simplify parsing durations. // Define a few aliases to simplify parsing durations.
@ -70,17 +71,17 @@ export function isTrueJoinEvent(event: any): boolean {
return membership === 'join' && prevMembership !== "join"; return membership === 'join' && prevMembership !== "join";
} }
export async function redactUserMessagesIn(mjolnir: Mjolnir, userIdOrGlob: string, targetRoomIds: string[], limit = 1000) { export async function redactUserMessagesIn(client: MatrixClient, managementRoom: ManagementRoomOutput, userIdOrGlob: string, targetRoomIds: string[], limit = 1000, noop = false) {
for (const targetRoomId of targetRoomIds) { for (const targetRoomId of targetRoomIds) {
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId); await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Fetching sent messages for ${userIdOrGlob} in ${targetRoomId} to redact...`, targetRoomId);
await getMessagesByUserIn(mjolnir.client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => { await getMessagesByUserIn(client, userIdOrGlob, targetRoomId, limit, async (eventsToRedact) => {
for (const victimEvent of eventsToRedact) { for (const victimEvent of eventsToRedact) {
await mjolnir.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId); await managementRoom.logMessage(LogLevel.DEBUG, "utils#redactUserMessagesIn", `Redacting ${victimEvent['event_id']} in ${targetRoomId}`, targetRoomId);
if (!mjolnir.config.noop) { if (!noop) {
await mjolnir.client.redactEvent(targetRoomId, victimEvent['event_id']); await client.redactEvent(targetRoomId, victimEvent['event_id']);
} else { } else {
await mjolnir.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId); await managementRoom.logMessage(LogLevel.WARN, "utils#redactUserMessagesIn", `Tried to redact ${victimEvent['event_id']} in ${targetRoomId} but Mjolnir is running in no-op mode`, targetRoomId);
} }
} }
}); });
@ -189,51 +190,6 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
} while (token && processed < limit) } while (token && processed < limit)
} }
/*
* Take an arbitrary string and a set of room IDs, and return a
* TextualMessageEventContent whose plaintext component replaces those room
* IDs with their canonical aliases, and whose html component replaces those
* room IDs with their matrix.to room pills.
*
* @param client The matrix client on which to query for room aliases
* @param text An arbitrary string to rewrite with room aliases and pills
* @param roomIds A set of room IDs to find and replace in `text`
* @param msgtype The desired message type of the returned TextualMessageEventContent
* @returns A TextualMessageEventContent with replaced room IDs
*/
export async function replaceRoomIdsWithPills(mjolnir: Mjolnir, text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
const content: TextualMessageEventContent = {
body: text,
formatted_body: htmlEscape(text),
msgtype: msgtype,
format: "org.matrix.custom.html",
};
const escapeRegex = (v: string): string => {
return v.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};
const viaServers = [(new UserID(await mjolnir.client.getUserId())).domain];
for (const roomId of roomIds) {
let alias = roomId;
try {
alias = (await mjolnir.client.getPublishedAlias(roomId)) || roomId;
} catch (e) {
// This is a recursive call, so tell the function not to try and call us
await mjolnir.logMessage(LogLevel.WARN, "utils", `Failed to resolve room alias for ${roomId} - see console for details`, null, true);
LogService.warn("utils", extractRequestError(e));
}
const regexRoomId = new RegExp(escapeRegex(roomId), "g");
content.body = content.body.replace(regexRoomId, alias);
if (content.formatted_body) {
const permalink = Permalinks.forRoom(alias, alias !== roomId ? [] : viaServers);
content.formatted_body = content.formatted_body.replace(regexRoomId, `<a href="${permalink}">${alias}</a>`);
}
}
return content;
}
let isMatrixClientPatchedForConciseExceptions = false; let isMatrixClientPatchedForConciseExceptions = false;
/** /**