Skip to content

Commit 5b445d2

Browse files
authored
Merge pull request #910 from the-draupnir-project/gnuxie/media-extraction
Use new `EventMixin` extraction API from MPS in protections. This allows protections to retrieve extensible events style mixins from any event. We also add a protection to automatically redact any event with an erroneous mixin that is likely to cause issues for other clients. MPS Describes all `m.room.message` `msgtype`s as mixins too.
2 parents e42ef15 + 2ad39d9 commit 5b445d2

12 files changed

+460
-146
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"jsdom": "^24.0.0",
6464
"matrix-appservice-bridge": "^10.3.1",
6565
"matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6",
66-
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.6.2",
66+
"matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.7.1",
6767
"matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/[email protected]",
6868
"pg": "^8.8.0",
6969
"yaml": "^2.3.2"

src/appservice/AppService.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,7 @@ export class MjolnirAppService {
382382
await this.bridge.close();
383383
await this.dataStore.close();
384384
await this.api.close();
385+
this.draupnirManager.unregisterListeners();
385386
}
386387

387388
/**

src/appservice/AppServiceDraupnirManager.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export class AppServiceDraupnirManager {
8383
return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID;
8484
}
8585

86+
public unregisterListeners(): void {
87+
this.baseManager.unregisterListeners();
88+
}
89+
8690
/**
8791
* Create the draupnir manager from the datastore and the access control.
8892
* @param dataStore The data store interface that has the details for provisioned draupnirs.

src/draupnirfactory/DraupnirProtectedRoomsSet.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import {
1212
ActionResult,
1313
ClientPlatform,
14+
DefaultMixinExtractor,
1415
LoggableConfigTracker,
1516
Logger,
1617
MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE,
@@ -219,6 +220,7 @@ export async function makeProtectedRoomsSet(
219220
protectedRoomsManager.ok,
220221
protectionsConfig.ok,
221222
userID,
223+
DefaultMixinExtractor,
222224
makeHandleMissingProtectionPermissions(
223225
clientPlatform.toRoomMessageSender(),
224226
managementRoom.toRoomIDOrAlias()

src/draupnirfactory/StandardDraupnirManager.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export class StandardDraupnirManager {
4242
// nothing to do.
4343
}
4444

45+
public unregisterListeners(): void {
46+
for (const [, draupnir] of this.draupnir) {
47+
draupnir.stop();
48+
}
49+
for (const [, safeModeDraupnir] of this.safeModeDraupnir) {
50+
safeModeDraupnir.stop();
51+
}
52+
}
53+
4554
public makeSafeModeToggle(
4655
clientUserID: StringUserID,
4756
managementRoom: MatrixRoomID,
@@ -52,6 +61,7 @@ export class StandardDraupnirManager {
5261
const draupnirManager = this;
5362
const toggle: SafeModeToggle = Object.freeze({
5463
async switchToSafeMode(cause: SafeModeCause) {
64+
draupnirManager.stopDraupnir(clientUserID);
5565
return draupnirManager.makeSafeModeDraupnir(
5666
clientUserID,
5767
managementRoom,
@@ -60,6 +70,7 @@ export class StandardDraupnirManager {
6070
);
6171
},
6272
async switchToDraupnir() {
73+
draupnirManager.stopDraupnir(clientUserID);
6374
return draupnirManager.makeDraupnir(
6475
clientUserID,
6576
managementRoom,
@@ -216,12 +227,15 @@ export class StandardDraupnirManager {
216227

217228
public stopDraupnir(clientUserID: StringUserID): void {
218229
const draupnir = this.draupnir.get(clientUserID);
219-
if (draupnir === undefined) {
220-
return;
221-
} else {
230+
if (draupnir !== undefined) {
222231
draupnir.stop();
223232
this.draupnir.delete(clientUserID);
224233
}
234+
const safeModeDraupnir = this.safeModeDraupnir.get(clientUserID);
235+
if (safeModeDraupnir) {
236+
safeModeDraupnir.stop();
237+
this.safeModeDraupnir.delete(clientUserID);
238+
}
225239
}
226240
}
227241

src/protections/DefaultEnabledProtectionsMigration.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { RedactionSynchronisationProtection } from "./RedactionSynchronisation";
1818
import { PolicyChangeNotification } from "./PolicyChangeNotification";
1919
import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection";
2020
import { RoomsSetBehaviour } from "./ProtectedRooms/RoomsSetBehaviourProtection";
21+
import { InvalidEventProtection } from "./InvalidEventProtection";
2122

2223
export const DefaultEnabledProtectionsMigration =
2324
new SchemedDataManager<MjolnirEnabledProtectionsEvent>([
@@ -165,4 +166,25 @@ export const DefaultEnabledProtectionsMigration =
165166
[DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
166167
});
167168
},
169+
async function enableInvalidEventProtection(input, toVersion) {
170+
if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) {
171+
return ActionError.Result(
172+
`The data for ${MjolnirEnabledProtectionsEventType} is corrupted.`
173+
);
174+
}
175+
const enabledProtections = new Set(input.enabled);
176+
const protection = findProtection(InvalidEventProtection.name);
177+
if (protection === undefined) {
178+
const message = `Cannot find the ${RoomsSetBehaviour.name} protection`;
179+
return ActionException.Result(message, {
180+
exception: new TypeError(message),
181+
exceptionKind: ActionExceptionKind.Unknown,
182+
});
183+
}
184+
enabledProtections.add(protection.name);
185+
return Ok({
186+
enabled: [...enabledProtections],
187+
[DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion,
188+
});
189+
},
168190
]);

src/protections/DraupnirProtectionsIndex.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import "./BanPropagation";
1616
import "./BasicFlooding";
1717
import "./FirstMessageIsImage";
1818
import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection";
19+
import "./InvalidEventProtection";
1920
import "./JoinWaveShortCircuit";
2021
import "./RedactionSynchronisation";
2122
import "./MembershipChangeProtection";
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright 2025 Gnuxie <[email protected]>
2+
//
3+
// SPDX-License-Identifier: AFL-3.0
4+
5+
import { Type } from "@sinclair/typebox";
6+
import {
7+
AbstractProtection,
8+
describeProtection,
9+
EDStatic,
10+
EventConsequences,
11+
EventWithMixins,
12+
isError,
13+
Logger,
14+
Ok,
15+
ProtectedRoomsSet,
16+
Protection,
17+
ProtectionDescription,
18+
RoomMessageSender,
19+
Task,
20+
UserConsequences,
21+
} from "matrix-protection-suite";
22+
import { Draupnir } from "../Draupnir";
23+
import {
24+
MatrixRoomID,
25+
StringRoomID,
26+
StringUserID,
27+
} from "@the-draupnir-project/matrix-basic-types";
28+
import { LazyLeakyBucket } from "../queues/LeakyBucket";
29+
import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager";
30+
import {
31+
renderMentionPill,
32+
sendMatrixEventsFromDeadDocument,
33+
} from "@the-draupnir-project/mps-interface-adaptor";
34+
35+
const log = new Logger("InvalidEventProtection");
36+
37+
const InvalidEventProtectionSettings = Type.Object(
38+
{
39+
warningText: Type.String({
40+
description:
41+
"The reason to use to notify the user after redacting their infringing message.",
42+
default:
43+
"You have sent an invalid event that could cause problems in some Matrix clients, so we have had to redact it.",
44+
}),
45+
},
46+
{
47+
title: "InvalidEventProtectionSettings",
48+
}
49+
);
50+
51+
type InvalidEventProtectionSettings = EDStatic<
52+
typeof InvalidEventProtectionSettings
53+
>;
54+
55+
export type InvalidEventProtectionDescription = ProtectionDescription<
56+
unknown,
57+
typeof InvalidEventProtectionSettings,
58+
InvalidEventProtectionCapabilities
59+
>;
60+
61+
export type InvalidEventProtectionCapabilities = {
62+
eventConsequences: EventConsequences;
63+
userConsequences: UserConsequences;
64+
};
65+
66+
export class InvalidEventProtection
67+
extends AbstractProtection<InvalidEventProtectionDescription>
68+
implements Protection<InvalidEventProtectionDescription>
69+
{
70+
private readonly eventConsequences: EventConsequences;
71+
private readonly userConsequences: UserConsequences;
72+
private readonly consequenceBucket = new LazyLeakyBucket<StringUserID>(
73+
1,
74+
30 * 60_000 // half an hour will do
75+
);
76+
public constructor(
77+
description: InvalidEventProtectionDescription,
78+
capabilities: InvalidEventProtectionCapabilities,
79+
protectedRoomsSet: ProtectedRoomsSet,
80+
private readonly warningText: string,
81+
private readonly roomMessageSender: RoomMessageSender,
82+
private readonly managentRoomID: StringRoomID
83+
) {
84+
super(description, capabilities, protectedRoomsSet, {});
85+
this.eventConsequences = capabilities.eventConsequences;
86+
this.userConsequences = capabilities.userConsequences;
87+
}
88+
89+
private async redactEventWithMixin(event: EventWithMixins): Promise<void> {
90+
const redactResult = await this.eventConsequences.consequenceForEvent(
91+
event.sourceEvent.room_id,
92+
event.sourceEvent.event_id,
93+
"invalid event mixin"
94+
);
95+
if (isError(redactResult)) {
96+
log.error(
97+
`Failed to redact and event sent by ${event.sourceEvent.sender} in ${event.sourceEvent.room_id}`,
98+
redactResult.error
99+
);
100+
}
101+
const managementRoomSendResult = await sendMatrixEventsFromDeadDocument(
102+
this.roomMessageSender,
103+
this.managentRoomID,
104+
<root>
105+
<details>
106+
<summary>
107+
Copy of invalid event content from {event.sourceEvent.sender}
108+
</summary>
109+
<pre>{JSON.stringify(event.sourceEvent.content)}</pre>
110+
</details>
111+
</root>,
112+
{}
113+
);
114+
if (isError(managementRoomSendResult)) {
115+
log.error(
116+
"Failed to send redacted event details to the management room",
117+
managementRoomSendResult.error
118+
);
119+
}
120+
}
121+
122+
private async sendWarning(event: EventWithMixins): Promise<void> {
123+
const result = await sendMatrixEventsFromDeadDocument(
124+
this.roomMessageSender,
125+
event.sourceEvent.room_id,
126+
<root>
127+
{renderMentionPill(event.sourceEvent.sender, event.sourceEvent.sender)}{" "}
128+
{this.warningText}
129+
</root>,
130+
{ replyToEvent: event.sourceEvent }
131+
);
132+
if (isError(result)) {
133+
log.error(
134+
"Unable to warn the user",
135+
event.sourceEvent.sender,
136+
result.error
137+
);
138+
}
139+
}
140+
141+
private async banUser(event: EventWithMixins): Promise<void> {
142+
const banResult = await this.userConsequences.consequenceForUserInRoom(
143+
event.sourceEvent.room_id,
144+
event.sourceEvent.sender,
145+
"Sending invalid events"
146+
);
147+
if (isError(banResult)) {
148+
log.error(
149+
"Unable to ban the sender of invalid events",
150+
event.sourceEvent.sender,
151+
event.sourceEvent.room_id,
152+
banResult.error
153+
);
154+
}
155+
}
156+
157+
public handleProtectionDisable(): void {
158+
this.consequenceBucket.stop();
159+
}
160+
161+
public handleTimelineEventMixins(
162+
_room: MatrixRoomID,
163+
event: EventWithMixins
164+
): void {
165+
if (!event.mixins.some((mixin) => mixin.isErroneous)) {
166+
return;
167+
}
168+
const infractions = this.consequenceBucket.getTokenCount(
169+
event.sourceEvent.sender
170+
);
171+
if (infractions > 0) {
172+
void Task(this.banUser(event), { log });
173+
} else {
174+
void Task(this.sendWarning(event), { log });
175+
}
176+
this.consequenceBucket.addToken(event.sourceEvent.sender);
177+
void Task(this.redactEventWithMixin(event), { log });
178+
}
179+
}
180+
181+
describeProtection<
182+
InvalidEventProtectionCapabilities,
183+
Draupnir,
184+
typeof InvalidEventProtectionSettings
185+
>({
186+
name: "InvalidEventProtection",
187+
description: `Protect the room against malicious events or evasion of other protections.`,
188+
capabilityInterfaces: {
189+
eventConsequences: "EventConsequences",
190+
userConsequences: "UserConsequences",
191+
},
192+
defaultCapabilities: {
193+
eventConsequences: "StandardEventConsequences",
194+
userConsequences: "StandardUserConsequences",
195+
},
196+
configSchema: InvalidEventProtectionSettings,
197+
factory: async (
198+
decription,
199+
protectedRoomsSet,
200+
draupnir,
201+
capabilitySet,
202+
settings
203+
) =>
204+
Ok(
205+
new InvalidEventProtection(
206+
decription,
207+
capabilitySet,
208+
protectedRoomsSet,
209+
settings.warningText,
210+
draupnir.clientPlatform.toRoomMessageSender(),
211+
draupnir.managementRoomID
212+
)
213+
),
214+
});

0 commit comments

Comments
 (0)