From 0bc511a002f8da31393e143feb12de3a07bc8692 Mon Sep 17 00:00:00 2001 From: Gnuxie <50846879+Gnuxie@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:02:07 +0000 Subject: [PATCH] Add a protection to stop excess membership changes. (#748) --- src/protections/DraupnirProtectionsIndex.ts | 1 + src/protections/JoinWaveShortCircuit.tsx | 6 +- .../MembershipChangeProtection.tsx | 207 ++++++++++++++++++ src/queues/LeakyBucket.ts | 44 +++- 4 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 src/protections/MembershipChangeProtection.tsx diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index 658de39e..8d8a7aef 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -14,6 +14,7 @@ import "./BasicFlooding"; import "./FirstMessageIsImage"; import "./JoinWaveShortCircuit"; import "./RedactionSynchronisation"; +import "./MembershipChangeProtection"; import "./MentionLimitProtection"; import "./MessageIsMedia"; import "./MessageIsVoice"; diff --git a/src/protections/JoinWaveShortCircuit.tsx b/src/protections/JoinWaveShortCircuit.tsx index 70b837f5..79754f9c 100644 --- a/src/protections/JoinWaveShortCircuit.tsx +++ b/src/protections/JoinWaveShortCircuit.tsx @@ -120,7 +120,7 @@ export class JoinWaveShortCircuitProtection extends AbstractProtection implements DraupnirProtection { - public joinBuckets: LeakyBucket; + public readonly joinBuckets: LeakyBucket; constructor( description: JoinWaveShortCircuitProtectionDescription, @@ -204,6 +204,10 @@ export class JoinWaveShortCircuitProtection private timescaleMilliseconds(): number { return this.settings.timescaleMinutes * ONE_MINUTE; } + + public handleProtectionDisable(): void { + this.joinBuckets.stop(); + } } const JoinWaveStatusCommand = describeCommand({ diff --git a/src/protections/MembershipChangeProtection.tsx b/src/protections/MembershipChangeProtection.tsx new file mode 100644 index 00000000..07d528e3 --- /dev/null +++ b/src/protections/MembershipChangeProtection.tsx @@ -0,0 +1,207 @@ +// Copyright 2025 Gnuxie +// +// SPDX-License-Identifier: Apache-2.0 + +import { + AbstractProtection, + EDStatic, + Logger, + Ok, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + RoomEvent, + RoomMessageSender, + SafeMembershipEvent, + SafeMembershipEventMirror, + UserConsequences, + describeProtection, +} from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { + MatrixRoomID, + StringRoomID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { Type } from "@sinclair/typebox"; +import { LazyLeakyBucket, LeakyBucket } from "../queues/LeakyBucket"; +import { isError, Result } from "@gnuxie/typescript-result"; +import { sendMatrixEventsFromDeadDocument } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor"; +import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer"; +import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; + +const DEFAULT_MAX_PER_TIMESCALE = 7; +const DEFAULT_TIMESCALE_MINUTES = 60; +const ONE_MINUTE = 60_000; // 1min in ms + +const log = new Logger("MembershipChangeProtection"); + +const MembershipChangeProtectionSettings = Type.Object( + { + maxChangesPerUser: Type.Integer({ + default: DEFAULT_MAX_PER_TIMESCALE, + description: + "The maximum number of membership changes that a single user can perform within the timescaleMinutes before the consequence is enacted.", + }), + timescaleMinutes: Type.Integer({ + default: DEFAULT_TIMESCALE_MINUTES, + description: + "The timescale in minutes over which the maxChangesPerUser is relevant before the consequence is enacted.", + }), + finalConsequenceReason: Type.String({ + default: + "You are changing your membership too frequently and have been removed as a precaution.", + description: "The reason given to the user when they are rate limited.", + }), + warningText: Type.String({ + default: + "Hi, you are changing your room membership too frequently, and may be temporarily banned as an automated precaution if you continue.", + description: + "The message to send to the user when they are nearing the rate limit.", + }), + }, + { title: "MembershipChangeProtectionSettings" } +); + +type MembershipChangeProtectionSettings = EDStatic< + typeof MembershipChangeProtectionSettings +>; + +function makeBucketKey(roomID: StringRoomID, userID: StringUserID): string { + return roomID + userID; +} + +export type MembershipChangeProtectionDescription = ProtectionDescription< + unknown, + typeof MembershipChangeProtectionSettings, + MembershipChangeProtectionCapabilities +>; + +export class MembershipChangeProtection + extends AbstractProtection + implements Protection +{ + private readonly finalConsequences: UserConsequences; + public readonly changeBucket: LeakyBucket; + // just a crap attempt to stop consequences being spammed + private readonly consequenceBucket = new LazyLeakyBucket( + 1, + this.timescaleMilliseconds() + ); + private readonly warningThreshold = Math.floor( + this.settings.maxChangesPerUser * 0.6 + ); + constructor( + description: MembershipChangeProtectionDescription, + capabilities: MembershipChangeProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly messageSender: RoomMessageSender, + public readonly settings: MembershipChangeProtectionSettings + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.finalConsequences = capabilities.finalConsequences; + this.changeBucket = new LazyLeakyBucket( + this.settings.maxChangesPerUser, + this.timescaleMilliseconds() + ); + } + + public async handleTimelineEvent( + room: MatrixRoomID, + event: RoomEvent + ): Promise> { + if (!SafeMembershipEventMirror.isSafeContent(event.content)) { + return Ok(undefined); + } + const safeEvent = event as SafeMembershipEvent; + if (safeEvent.sender !== safeEvent.state_key) { + return Ok(undefined); // they're being banned or kicked. + } + const key = makeBucketKey(event.room_id, safeEvent.state_key); + const numberOfChanges = this.changeBucket.addToken(key); + if ( + numberOfChanges >= this.warningThreshold && + this.consequenceBucket.getTokenCount(key) === 0 + ) { + this.consequenceBucket.addToken(key); + const warningResult = await sendMatrixEventsFromDeadDocument( + this.messageSender, + safeEvent.room_id, + + {renderMentionPill( + safeEvent.state_key, + safeEvent.content.displayname ?? safeEvent.state_key + )}{" "} + {this.settings.warningText} + , + { replyToEvent: safeEvent } + ); + if (isError(warningResult)) { + log.error( + "Failed to send warning message to user", + safeEvent.state_key, + warningResult.error + ); + } + } + if ( + numberOfChanges > this.settings.maxChangesPerUser && + this.consequenceBucket.getTokenCount(key) === 1 + ) { + this.consequenceBucket.addToken(key); + const consequenceResult = + await this.finalConsequences.consequenceForUserInRoom( + room.toRoomIDOrAlias(), + safeEvent.state_key, + this.settings.finalConsequenceReason + ); + if (isError(consequenceResult)) { + log.error( + "Failed to enact consequence for user", + safeEvent.state_key, + consequenceResult.error + ); + } + } + return Ok(undefined); + } + + public handleProtectionDisable(): void { + this.changeBucket.stop(); + this.consequenceBucket.stop(); + } + + private timescaleMilliseconds(): number { + return this.settings.timescaleMinutes * ONE_MINUTE; + } +} + +export type MembershipChangeProtectionCapabilities = { + finalConsequences: UserConsequences; +}; + +describeProtection< + MembershipChangeProtectionCapabilities, + Draupnir, + typeof MembershipChangeProtectionSettings +>({ + name: MembershipChangeProtection.name, + description: `A protection that will rate limit the number of changes a single user can make to their membership event. Experimental.`, + capabilityInterfaces: { + finalConsequences: "UserConsequences", + }, + defaultCapabilities: { + finalConsequences: "StandardUserConsequences", + }, + configSchema: MembershipChangeProtectionSettings, + factory: (decription, protectedRoomsSet, draupnir, capabilitySet, settings) => + Ok( + new MembershipChangeProtection( + decription, + capabilitySet, + protectedRoomsSet, + draupnir.clientPlatform.toRoomMessageSender(), + settings + ) + ), +}); diff --git a/src/queues/LeakyBucket.ts b/src/queues/LeakyBucket.ts index 69097e7b..e5ee78f5 100644 --- a/src/queues/LeakyBucket.ts +++ b/src/queues/LeakyBucket.ts @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2024 Gnuxie +// SPDX-FileCopyrightText: 2024 - 2025 Gnuxie // // SPDX-License-Identifier: Apache-2.0 @@ -9,6 +9,7 @@ export interface LeakyBucket { addToken(key: Key): number; getTokenCount(key: Key): number; getAllTokens(): Map; + stop(): void; } type BucketEntry = { @@ -26,12 +27,15 @@ type BucketEntry = { export class LazyLeakyBucket implements LeakyBucket { private readonly buckets: Map = new Map(); private readonly leakDelta: number; + private isDisposed = false; + private leakCycleTimeout: NodeJS.Timeout | null = null; public constructor( private readonly capacity: number, private readonly timescale: number ) { this.leakDelta = this.timescale / this.capacity; + this.startLeakCycle(); } getAllTokens(): Map { const map = new Map(); @@ -41,13 +45,16 @@ export class LazyLeakyBucket implements LeakyBucket { return map; } - private leak(now: Date, entry: BucketEntry): void { + private leak(now: Date, key: Key, entry: BucketEntry): void { const elapsed = now.getTime() - entry.lastLeak.getTime(); const tokensToRemove = Math.floor(elapsed / this.timescale); entry.tokens = Math.max(entry.tokens - tokensToRemove, 0); entry.lastLeak = new Date( entry.lastLeak.getTime() + tokensToRemove * this.leakDelta ); + if (entry.tokens < 1) { + this.buckets.delete(key); + } } public addToken(key: Key): number { @@ -60,7 +67,8 @@ export class LazyLeakyBucket implements LeakyBucket { }); return 1; } - this.leak(now, entry); + entry.tokens += 1; + this.leak(now, key, entry); return entry.tokens; } @@ -70,7 +78,35 @@ export class LazyLeakyBucket implements LeakyBucket { if (entry === undefined) { return 0; } - this.leak(now, entry); + this.leak(now, key, entry); return entry.tokens; } + + private leakAll(): void { + const now = new Date(); + for (const [key, entry] of this.buckets.entries()) { + this.leak(now, key, entry); + } + } + + /** + * Periodically leak all of the buckets to prevent memory leaks from leftover + * keys. + */ + private startLeakCycle(): void { + if (this.isDisposed) { + return; + } + this.leakCycleTimeout = setTimeout(() => { + this.leakAll(); + this.startLeakCycle(); + }, this.timescale); + } + + public stop(): void { + this.isDisposed = true; + if (this.leakCycleTimeout) { + clearTimeout(this.leakCycleTimeout); + } + } }