Add Join Wave Short-Circuit Protection (#280)

* Add Short Circuit Protection

* fix module name

* change to dynamic timescales

* address feedback
This commit is contained in:
Jonathan de Jong 2022-05-06 10:24:34 +02:00 committed by GitHub
parent c8caf744c5
commit 9fce35c000
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 159 additions and 0 deletions

View File

@ -751,6 +751,7 @@ export class Mjolnir {
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
const errors: RoomUpdateError[] = [];
const additionalPermissions = this.requiredProtectionPermissions();
try {
const ownUserId = await this.client.getUserId();
@ -808,6 +809,20 @@ export class Mjolnir {
});
}
// 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));
@ -821,6 +836,10 @@ export class Mjolnir {
return errors;
}
private requiredProtectionPermissions(): Set<string> {
return new Set(this.enabledProtections.map((p) => p.requiredStatePermissions).flat())
}
/**
* @returns The protected rooms ordered by the most recently active first.
*/

View File

@ -28,6 +28,7 @@ export abstract class Protection {
abstract readonly name: string
abstract readonly description: string;
enabled = false;
readonly requiredStatePermissions: string[] = [];
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
/*

View File

@ -0,0 +1,137 @@
/*
Copyright 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 {Protection} from "./IProtection";
import {Mjolnir} from "../Mjolnir";
import {NumberProtectionSetting} from "./ProtectionSettings";
import {LogLevel} from "matrix-bot-sdk";
import config from "../config";
const DEFAULT_MAX_PER_TIMESCALE = 50;
const DEFAULT_TIMESCALE_MINUTES = 60;
const ONE_MINUTE = 60_000; // 1min in ms
export class JoinWaveShortCircuit extends Protection {
requiredStatePermissions = ["m.room.join_rules"]
private joinBuckets: {
[roomId: string]: {
lastBucketStart: Date,
numberOfJoins: number,
}
} = {};
settings = {
maxPer: new NumberProtectionSetting(DEFAULT_MAX_PER_TIMESCALE),
timescaleMinutes: new NumberProtectionSetting(DEFAULT_TIMESCALE_MINUTES)
};
constructor() {
super();
}
public get name(): string {
return "JoinWaveShortCircuit";
}
public get description(): string {
return "If X amount of users join in Y time, set the room to invite-only."
}
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any) {
if (event['type'] !== 'm.room.member') {
// Not a join/leave event.
return;
}
if (!(roomId in mjolnir.protectedRooms)) {
// Not a room we are watching.
return;
}
const userId = event['state_key'];
if (!userId) {
// Ill-formed event.
return;
}
const newMembership = event['content']['membership'];
const prevMembership = event['unsigned']?.['prev_content']?.['membership'] || null;
// We look at the previous membership to filter out profile changes
if (newMembership === 'join' && prevMembership !== "join") {
// A new join, fallthrough
} else {
return;
}
// If either the roomId bucket didn't exist, or the bucket has expired, create a new one
if (!this.joinBuckets[roomId] || this.hasExpired(this.joinBuckets[roomId].lastBucketStart)) {
this.joinBuckets[roomId] = {
lastBucketStart: new Date(),
numberOfJoins: 0
}
}
if (++this.joinBuckets[roomId].numberOfJoins >= this.settings.maxPer.value) {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Setting ${roomId} to invite-only as more than ${this.settings.maxPer.value} users have joined over the last ${this.settings.timescaleMinutes.value} minutes (since ${this.joinBuckets[roomId].lastBucketStart})`, roomId);
if (!config.noop) {
await mjolnir.client.sendStateEvent(roomId, "m.room.join_rules", "", {"join_rule": "invite"})
} else {
await mjolnir.logMessage(LogLevel.WARN, "JoinWaveShortCircuit", `Tried to set ${roomId} to invite-only, but Mjolnir is running in no-op mode`, roomId);
}
}
}
private hasExpired(at: Date): boolean {
return ((new Date()).getTime() - at.getTime()) > this.timescaleMilliseconds()
}
private timescaleMilliseconds(): number {
return (this.settings.timescaleMinutes.value * ONE_MINUTE)
}
public async statusCommand(mjolnir: Mjolnir, subcommand: string[]): Promise<{ html: string, text: string }> {
const withExpired = subcommand.includes("withExpired");
const withStart = subcommand.includes("withStart");
let html = `<b>Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes}):</b><br/><ul>`;
let text = `Short Circuit join buckets (max ${this.settings.maxPer.value} per ${this.settings.timescaleMinutes.value} minutes):\n`;
for (const roomId of Object.keys(this.joinBuckets)) {
const bucket = this.joinBuckets[roomId];
const isExpired = this.hasExpired(bucket.lastBucketStart);
if (isExpired && !withExpired) {
continue;
}
const startText = withStart ? ` (since ${bucket.lastBucketStart})` : "";
const expiredText = isExpired ? ` (bucket expired since ${new Date(bucket.lastBucketStart.getTime() + this.timescaleMilliseconds())})` : "";
html += `<li><a href="https://matrix.to/#/${roomId}">${roomId}</a>: ${bucket.numberOfJoins} joins${startText}${expiredText}.</li>`;
text += `* ${roomId}: ${bucket.numberOfJoins} joins${startText}${expiredText}.\n`;
}
html += "</ul>";
return {
html,
text,
}
}
}

View File

@ -22,6 +22,7 @@ import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia";
import { TrustedReporters } from "./TrustedReporters";
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
export const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
@ -31,4 +32,5 @@ export const PROTECTIONS: Protection[] = [
new MessageIsMedia(),
new TrustedReporters(),
new DetectFederationLag(),
new JoinWaveShortCircuit(),
];