Skip to content

Commit 0bc511a

Browse files
authored
Add a protection to stop excess membership changes. (#748)
1 parent 6ad94dc commit 0bc511a

File tree

4 files changed

+253
-5
lines changed

4 files changed

+253
-5
lines changed

src/protections/DraupnirProtectionsIndex.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import "./BasicFlooding";
1414
import "./FirstMessageIsImage";
1515
import "./JoinWaveShortCircuit";
1616
import "./RedactionSynchronisation";
17+
import "./MembershipChangeProtection";
1718
import "./MentionLimitProtection";
1819
import "./MessageIsMedia";
1920
import "./MessageIsVoice";

src/protections/JoinWaveShortCircuit.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export class JoinWaveShortCircuitProtection
120120
extends AbstractProtection<JoinWaveShortCircuitProtectionDescription>
121121
implements DraupnirProtection<JoinWaveShortCircuitProtectionDescription>
122122
{
123-
public joinBuckets: LeakyBucket<StringRoomID>;
123+
public readonly joinBuckets: LeakyBucket<StringRoomID>;
124124

125125
constructor(
126126
description: JoinWaveShortCircuitProtectionDescription,
@@ -204,6 +204,10 @@ export class JoinWaveShortCircuitProtection
204204
private timescaleMilliseconds(): number {
205205
return this.settings.timescaleMinutes * ONE_MINUTE;
206206
}
207+
208+
public handleProtectionDisable(): void {
209+
this.joinBuckets.stop();
210+
}
207211
}
208212

209213
const JoinWaveStatusCommand = describeCommand({
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Copyright 2025 Gnuxie <[email protected]>
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
import {
6+
AbstractProtection,
7+
EDStatic,
8+
Logger,
9+
Ok,
10+
ProtectedRoomsSet,
11+
Protection,
12+
ProtectionDescription,
13+
RoomEvent,
14+
RoomMessageSender,
15+
SafeMembershipEvent,
16+
SafeMembershipEventMirror,
17+
UserConsequences,
18+
describeProtection,
19+
} from "matrix-protection-suite";
20+
import { Draupnir } from "../Draupnir";
21+
import {
22+
MatrixRoomID,
23+
StringRoomID,
24+
StringUserID,
25+
} from "@the-draupnir-project/matrix-basic-types";
26+
import { Type } from "@sinclair/typebox";
27+
import { LazyLeakyBucket, LeakyBucket } from "../queues/LeakyBucket";
28+
import { isError, Result } from "@gnuxie/typescript-result";
29+
import { sendMatrixEventsFromDeadDocument } from "../commands/interface-manager/MPSMatrixInterfaceAdaptor";
30+
import { renderMentionPill } from "../commands/interface-manager/MatrixHelpRenderer";
31+
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
32+
33+
const DEFAULT_MAX_PER_TIMESCALE = 7;
34+
const DEFAULT_TIMESCALE_MINUTES = 60;
35+
const ONE_MINUTE = 60_000; // 1min in ms
36+
37+
const log = new Logger("MembershipChangeProtection");
38+
39+
const MembershipChangeProtectionSettings = Type.Object(
40+
{
41+
maxChangesPerUser: Type.Integer({
42+
default: DEFAULT_MAX_PER_TIMESCALE,
43+
description:
44+
"The maximum number of membership changes that a single user can perform within the timescaleMinutes before the consequence is enacted.",
45+
}),
46+
timescaleMinutes: Type.Integer({
47+
default: DEFAULT_TIMESCALE_MINUTES,
48+
description:
49+
"The timescale in minutes over which the maxChangesPerUser is relevant before the consequence is enacted.",
50+
}),
51+
finalConsequenceReason: Type.String({
52+
default:
53+
"You are changing your membership too frequently and have been removed as a precaution.",
54+
description: "The reason given to the user when they are rate limited.",
55+
}),
56+
warningText: Type.String({
57+
default:
58+
"Hi, you are changing your room membership too frequently, and may be temporarily banned as an automated precaution if you continue.",
59+
description:
60+
"The message to send to the user when they are nearing the rate limit.",
61+
}),
62+
},
63+
{ title: "MembershipChangeProtectionSettings" }
64+
);
65+
66+
type MembershipChangeProtectionSettings = EDStatic<
67+
typeof MembershipChangeProtectionSettings
68+
>;
69+
70+
function makeBucketKey(roomID: StringRoomID, userID: StringUserID): string {
71+
return roomID + userID;
72+
}
73+
74+
export type MembershipChangeProtectionDescription = ProtectionDescription<
75+
unknown,
76+
typeof MembershipChangeProtectionSettings,
77+
MembershipChangeProtectionCapabilities
78+
>;
79+
80+
export class MembershipChangeProtection
81+
extends AbstractProtection<MembershipChangeProtectionDescription>
82+
implements Protection<MembershipChangeProtectionDescription>
83+
{
84+
private readonly finalConsequences: UserConsequences;
85+
public readonly changeBucket: LeakyBucket<string>;
86+
// just a crap attempt to stop consequences being spammed
87+
private readonly consequenceBucket = new LazyLeakyBucket(
88+
1,
89+
this.timescaleMilliseconds()
90+
);
91+
private readonly warningThreshold = Math.floor(
92+
this.settings.maxChangesPerUser * 0.6
93+
);
94+
constructor(
95+
description: MembershipChangeProtectionDescription,
96+
capabilities: MembershipChangeProtectionCapabilities,
97+
protectedRoomsSet: ProtectedRoomsSet,
98+
private readonly messageSender: RoomMessageSender,
99+
public readonly settings: MembershipChangeProtectionSettings
100+
) {
101+
super(description, capabilities, protectedRoomsSet, {});
102+
this.finalConsequences = capabilities.finalConsequences;
103+
this.changeBucket = new LazyLeakyBucket(
104+
this.settings.maxChangesPerUser,
105+
this.timescaleMilliseconds()
106+
);
107+
}
108+
109+
public async handleTimelineEvent(
110+
room: MatrixRoomID,
111+
event: RoomEvent
112+
): Promise<Result<void>> {
113+
if (!SafeMembershipEventMirror.isSafeContent(event.content)) {
114+
return Ok(undefined);
115+
}
116+
const safeEvent = event as SafeMembershipEvent;
117+
if (safeEvent.sender !== safeEvent.state_key) {
118+
return Ok(undefined); // they're being banned or kicked.
119+
}
120+
const key = makeBucketKey(event.room_id, safeEvent.state_key);
121+
const numberOfChanges = this.changeBucket.addToken(key);
122+
if (
123+
numberOfChanges >= this.warningThreshold &&
124+
this.consequenceBucket.getTokenCount(key) === 0
125+
) {
126+
this.consequenceBucket.addToken(key);
127+
const warningResult = await sendMatrixEventsFromDeadDocument(
128+
this.messageSender,
129+
safeEvent.room_id,
130+
<root>
131+
{renderMentionPill(
132+
safeEvent.state_key,
133+
safeEvent.content.displayname ?? safeEvent.state_key
134+
)}{" "}
135+
{this.settings.warningText}
136+
</root>,
137+
{ replyToEvent: safeEvent }
138+
);
139+
if (isError(warningResult)) {
140+
log.error(
141+
"Failed to send warning message to user",
142+
safeEvent.state_key,
143+
warningResult.error
144+
);
145+
}
146+
}
147+
if (
148+
numberOfChanges > this.settings.maxChangesPerUser &&
149+
this.consequenceBucket.getTokenCount(key) === 1
150+
) {
151+
this.consequenceBucket.addToken(key);
152+
const consequenceResult =
153+
await this.finalConsequences.consequenceForUserInRoom(
154+
room.toRoomIDOrAlias(),
155+
safeEvent.state_key,
156+
this.settings.finalConsequenceReason
157+
);
158+
if (isError(consequenceResult)) {
159+
log.error(
160+
"Failed to enact consequence for user",
161+
safeEvent.state_key,
162+
consequenceResult.error
163+
);
164+
}
165+
}
166+
return Ok(undefined);
167+
}
168+
169+
public handleProtectionDisable(): void {
170+
this.changeBucket.stop();
171+
this.consequenceBucket.stop();
172+
}
173+
174+
private timescaleMilliseconds(): number {
175+
return this.settings.timescaleMinutes * ONE_MINUTE;
176+
}
177+
}
178+
179+
export type MembershipChangeProtectionCapabilities = {
180+
finalConsequences: UserConsequences;
181+
};
182+
183+
describeProtection<
184+
MembershipChangeProtectionCapabilities,
185+
Draupnir,
186+
typeof MembershipChangeProtectionSettings
187+
>({
188+
name: MembershipChangeProtection.name,
189+
description: `A protection that will rate limit the number of changes a single user can make to their membership event. Experimental.`,
190+
capabilityInterfaces: {
191+
finalConsequences: "UserConsequences",
192+
},
193+
defaultCapabilities: {
194+
finalConsequences: "StandardUserConsequences",
195+
},
196+
configSchema: MembershipChangeProtectionSettings,
197+
factory: (decription, protectedRoomsSet, draupnir, capabilitySet, settings) =>
198+
Ok(
199+
new MembershipChangeProtection(
200+
decription,
201+
capabilitySet,
202+
protectedRoomsSet,
203+
draupnir.clientPlatform.toRoomMessageSender(),
204+
settings
205+
)
206+
),
207+
});

src/queues/LeakyBucket.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// SPDX-FileCopyrightText: 2024 Gnuxie <[email protected]>
1+
// SPDX-FileCopyrightText: 2024 - 2025 Gnuxie <[email protected]>
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

@@ -9,6 +9,7 @@ export interface LeakyBucket<Key> {
99
addToken(key: Key): number;
1010
getTokenCount(key: Key): number;
1111
getAllTokens(): Map<Key, number>;
12+
stop(): void;
1213
}
1314

1415
type BucketEntry = {
@@ -26,12 +27,15 @@ type BucketEntry = {
2627
export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
2728
private readonly buckets: Map<Key, BucketEntry> = new Map();
2829
private readonly leakDelta: number;
30+
private isDisposed = false;
31+
private leakCycleTimeout: NodeJS.Timeout | null = null;
2932

3033
public constructor(
3134
private readonly capacity: number,
3235
private readonly timescale: number
3336
) {
3437
this.leakDelta = this.timescale / this.capacity;
38+
this.startLeakCycle();
3539
}
3640
getAllTokens(): Map<Key, number> {
3741
const map = new Map<Key, number>();
@@ -41,13 +45,16 @@ export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
4145
return map;
4246
}
4347

44-
private leak(now: Date, entry: BucketEntry): void {
48+
private leak(now: Date, key: Key, entry: BucketEntry): void {
4549
const elapsed = now.getTime() - entry.lastLeak.getTime();
4650
const tokensToRemove = Math.floor(elapsed / this.timescale);
4751
entry.tokens = Math.max(entry.tokens - tokensToRemove, 0);
4852
entry.lastLeak = new Date(
4953
entry.lastLeak.getTime() + tokensToRemove * this.leakDelta
5054
);
55+
if (entry.tokens < 1) {
56+
this.buckets.delete(key);
57+
}
5158
}
5259

5360
public addToken(key: Key): number {
@@ -60,7 +67,8 @@ export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
6067
});
6168
return 1;
6269
}
63-
this.leak(now, entry);
70+
entry.tokens += 1;
71+
this.leak(now, key, entry);
6472
return entry.tokens;
6573
}
6674

@@ -70,7 +78,35 @@ export class LazyLeakyBucket<Key> implements LeakyBucket<Key> {
7078
if (entry === undefined) {
7179
return 0;
7280
}
73-
this.leak(now, entry);
81+
this.leak(now, key, entry);
7482
return entry.tokens;
7583
}
84+
85+
private leakAll(): void {
86+
const now = new Date();
87+
for (const [key, entry] of this.buckets.entries()) {
88+
this.leak(now, key, entry);
89+
}
90+
}
91+
92+
/**
93+
* Periodically leak all of the buckets to prevent memory leaks from leftover
94+
* keys.
95+
*/
96+
private startLeakCycle(): void {
97+
if (this.isDisposed) {
98+
return;
99+
}
100+
this.leakCycleTimeout = setTimeout(() => {
101+
this.leakAll();
102+
this.startLeakCycle();
103+
}, this.timescale);
104+
}
105+
106+
public stop(): void {
107+
this.isDisposed = true;
108+
if (this.leakCycleTimeout) {
109+
clearTimeout(this.leakCycleTimeout);
110+
}
111+
}
76112
}

0 commit comments

Comments
 (0)