mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-07-03 01:51:30 +00: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[]> {
|
private async verifyPermissionsIn(roomId: string): Promise<RoomUpdateError[]> {
|
||||||
const errors: RoomUpdateError[] = [];
|
const errors: RoomUpdateError[] = [];
|
||||||
|
const additionalPermissions = this.requiredProtectionPermissions();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ownUserId = await this.client.getUserId();
|
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
|
// Otherwise OK
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LogService.error("Mjolnir", extractRequestError(e));
|
LogService.error("Mjolnir", extractRequestError(e));
|
||||||
|
@ -821,6 +836,10 @@ export class Mjolnir {
|
||||||
return errors;
|
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.
|
* @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 name: string
|
||||||
abstract readonly description: string;
|
abstract readonly description: string;
|
||||||
enabled = false;
|
enabled = false;
|
||||||
|
readonly requiredStatePermissions: string[] = [];
|
||||||
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
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 { MessageIsVoice } from "./MessageIsVoice";
|
||||||
import { MessageIsMedia } from "./MessageIsMedia";
|
import { MessageIsMedia } from "./MessageIsMedia";
|
||||||
import { TrustedReporters } from "./TrustedReporters";
|
import { TrustedReporters } from "./TrustedReporters";
|
||||||
|
import { JoinWaveShortCircuit } from "./JoinWaveShortCircuit";
|
||||||
|
|
||||||
export const PROTECTIONS: Protection[] = [
|
export const PROTECTIONS: Protection[] = [
|
||||||
new FirstMessageIsImage(),
|
new FirstMessageIsImage(),
|
||||||
|
@ -31,4 +32,5 @@ export const PROTECTIONS: Protection[] = [
|
||||||
new MessageIsMedia(),
|
new MessageIsMedia(),
|
||||||
new TrustedReporters(),
|
new TrustedReporters(),
|
||||||
new DetectFederationLag(),
|
new DetectFederationLag(),
|
||||||
|
new JoinWaveShortCircuit(),
|
||||||
];
|
];
|
||||||
|
|
Loading…
Reference in New Issue
Block a user