Add a protection to stop excess membership changes. (#748)

This commit is contained in:
Gnuxie
2025-03-07 18:02:07 +00:00
committed by GitHub
parent 6ad94dc1cb
commit 0bc511a002
4 changed files with 253 additions and 5 deletions
@@ -14,6 +14,7 @@ import "./BasicFlooding";
import "./FirstMessageIsImage";
import "./JoinWaveShortCircuit";
import "./RedactionSynchronisation";
import "./MembershipChangeProtection";
import "./MentionLimitProtection";
import "./MessageIsMedia";
import "./MessageIsVoice";
+5 -1
View File
@@ -120,7 +120,7 @@ export class JoinWaveShortCircuitProtection
extends AbstractProtection<JoinWaveShortCircuitProtectionDescription>
implements DraupnirProtection<JoinWaveShortCircuitProtectionDescription>
{
public joinBuckets: LeakyBucket<StringRoomID>;
public readonly joinBuckets: LeakyBucket<StringRoomID>;
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({
@@ -0,0 +1,207 @@
// Copyright 2025 Gnuxie <Gnuxie@protonmail.com>
//
// 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<MembershipChangeProtectionDescription>
implements Protection<MembershipChangeProtectionDescription>
{
private readonly finalConsequences: UserConsequences;
public readonly changeBucket: LeakyBucket<string>;
// 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<Result<void>> {
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,
<root>
{renderMentionPill(
safeEvent.state_key,
safeEvent.content.displayname ?? safeEvent.state_key
)}{" "}
{this.settings.warningText}
</root>,
{ 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
)
),
});
+40 -4
View File
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 Gnuxie <Gnuxie@protonmail.com>
// SPDX-FileCopyrightText: 2024 - 2025 Gnuxie <Gnuxie@protonmail.com>
//
// SPDX-License-Identifier: Apache-2.0
@@ -9,6 +9,7 @@ export interface LeakyBucket<Key> {
addToken(key: Key): number;
getTokenCount(key: Key): number;
getAllTokens(): Map<Key, number>;
stop(): void;
}
type BucketEntry = {
@@ -26,12 +27,15 @@ type BucketEntry = {
export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
private readonly buckets: Map<Key, BucketEntry> = 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<Key, number> {
const map = new Map<Key, number>();
@@ -41,13 +45,16 @@ export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
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<Key> implements LeakyBucket<Key> {
});
return 1;
}
this.leak(now, entry);
entry.tokens += 1;
this.leak(now, key, entry);
return entry.tokens;
}
@@ -70,7 +78,35 @@ export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
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);
}
}
}