mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-10-01 01:36:06 -04:00
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:
parent
c8caf744c5
commit
9fce35c000
@ -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.
|
||||
*/
|
||||
|
@ -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> };
|
||||
|
||||
/*
|
||||
|
137
src/protections/JoinWaveShortCircuit.ts
Normal file
137
src/protections/JoinWaveShortCircuit.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@ -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(),
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user