From e0dc7b77d1151bbe78de35d1d57bceb38ce14151 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Wed, 18 Jun 2025 18:10:28 +0100 Subject: [PATCH 1/7] Update `MentionLimitProtection` to use `handleTimelineMedia`. This is a better API provided by MPS that already handles safe parsing of Matrix events to extract relevant media. --- src/protections/MentionLimitProtection.tsx | 121 ++++++++---------- .../protections/MentionLimitProtectionTest.ts | 17 ++- 2 files changed, 66 insertions(+), 72 deletions(-) diff --git a/src/protections/MentionLimitProtection.tsx b/src/protections/MentionLimitProtection.tsx index ff4b30d..0eda009 100644 --- a/src/protections/MentionLimitProtection.tsx +++ b/src/protections/MentionLimitProtection.tsx @@ -5,19 +5,18 @@ import { AbstractProtection, - ActionResult, EDStatic, EventConsequences, Logger, + MediaMixinTypes, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, - RoomEvent, RoomMessageSender, + SafeMediaEvent, Task, UserConsequences, - Value, describeProtection, isError, } from "matrix-protection-suite"; @@ -33,51 +32,32 @@ import { renderMentionPill, sendMatrixEventsFromDeadDocument, } from "@the-draupnir-project/mps-interface-adaptor"; +import { Result } from "@gnuxie/typescript-result"; const log = new Logger("MentionLimitProtection"); -const MentionsContentSchema = Type.Object({ - "m.mentions": Type.Object({ - user_ids: Type.Array(Type.String()), - }), -}); - -const NewContentMentionsSchema = Type.Object({ - "m.new_content": MentionsContentSchema, -}); - -const WeakTextContentSchema = Type.Object({ - body: Type.Optional(Type.String()), - formatted_body: Type.Optional(Type.String()), -}); - export function isContainingMentionsOverLimit( - event: RoomEvent, + event: SafeMediaEvent, maxMentions: number, checkBody: boolean ): boolean { + const bodyMedia = event.media.filter( + (mixin) => mixin.mixinType === MediaMixinTypes.Body + ); + const mentionMedia = event.media.filter( + (mixin) => mixin.mixinType === MediaMixinTypes.Mentions + ); const isOverLimit = (user_ids: string[]): boolean => user_ids.length > maxMentions; - if ( - Value.Check(NewContentMentionsSchema, event.content) && - isOverLimit(event.content["m.new_content"]["m.mentions"].user_ids) - ) { + if (mentionMedia.some((mixin) => isOverLimit(mixin.user_ids))) { return true; } if ( - Value.Check(MentionsContentSchema, event.content) && - isOverLimit(event.content["m.mentions"].user_ids) + checkBody && + bodyMedia.some((mixin) => mixin.body.split("@").length - 1 > maxMentions) ) { return true; } - if (checkBody && Value.Check(WeakTextContentSchema, event.content)) { - if ( - event.content.body !== undefined && - event.content.body.split("@").length - 1 > maxMentions - ) { - return true; - } - } return false; } @@ -141,12 +121,9 @@ export class MentionLimitProtection this.warningText = settings.warningText; this.includeLegacymentions = settings.includeLegacyMentions; } - public async handleTimelineEvent( - _room: MatrixRoomID, - event: RoomEvent - ): Promise> { + public handleTimelineMedia(_room: MatrixRoomID, event: SafeMediaEvent): void { if (event.sender === this.protectedRoomsSet.userID) { - return Ok(undefined); + return; } if ( isContainingMentionsOverLimit( @@ -155,42 +132,48 @@ export class MentionLimitProtection this.includeLegacymentions ) ) { - const infractions = this.consequenceBucket.getTokenCount(event.sender); - if (infractions > 0) { - const userResult = await this.userConsequences.consequenceForUserInRoom( - event.room_id, - event.sender, - this.warningText - ); - if (isError(userResult)) { - log.error("Failed to ban the user", event.sender, userResult.error); - } - // fall through to the event consequence on purpose so we redact the event too. - } else { - // if they're not being banned we need to tell them why their message got redacted. - void Task( - sendMatrixEventsFromDeadDocument( - this.roomMessageSender, - event.room_id, - - {renderMentionPill(event.sender, event.sender)} {this.warningText} - , - { replyToEvent: event } - ), - { - log, - } - ); - } - this.consequenceBucket.addToken(event.sender); - return await this.eventConsequences.consequenceForEvent( + void Task(this.handleEventOverLimit(event), { + log, + }); + } + } + + public async handleEventOverLimit( + event: SafeMediaEvent + ): Promise> { + const infractions = this.consequenceBucket.getTokenCount(event.sender); + if (infractions > 0) { + const userResult = await this.userConsequences.consequenceForUserInRoom( event.room_id, - event.event_id, + event.sender, this.warningText ); + if (isError(userResult)) { + log.error("Failed to ban the user", event.sender, userResult.error); + } + // fall through to the event consequence on purpose so we redact the event too. } else { - return Ok(undefined); + // if they're not being banned we need to tell them why their message got redacted. + void Task( + sendMatrixEventsFromDeadDocument( + this.roomMessageSender, + event.room_id, + + {renderMentionPill(event.sender, event.sender)} {this.warningText} + , + { replyToEvent: event.sourceEvent } + ), + { + log, + } + ); } + this.consequenceBucket.addToken(event.sender); + return await this.eventConsequences.consequenceForEvent( + event.room_id, + event.event_id, + this.warningText + ); } } diff --git a/test/unit/protections/MentionLimitProtectionTest.ts b/test/unit/protections/MentionLimitProtectionTest.ts index f63e4f6..02d953e 100644 --- a/test/unit/protections/MentionLimitProtectionTest.ts +++ b/test/unit/protections/MentionLimitProtectionTest.ts @@ -3,7 +3,13 @@ // // SPDX-License-Identifier: Apache-2.0 -import { RoomEvent } from "matrix-protection-suite"; +import { + extractSafeMediaEvent, + randomRoomID, + randomUserID, + RoomEvent, + SafeMediaEvent, +} from "matrix-protection-suite"; import { isContainingMentionsOverLimit } from "../../../src/protections/MentionLimitProtection"; import expect from "expect"; @@ -11,8 +17,13 @@ function messageEvent(content: { body?: string; formatted_body?: string; "m.mentions"?: { user_ids: string[] }; -}): RoomEvent { - return { content } as RoomEvent; +}): SafeMediaEvent { + return extractSafeMediaEvent({ + content, + type: "m.room.message", + sender: randomUserID(), + room_id: randomRoomID([]).toRoomIDOrAlias(), + } as RoomEvent); } describe("MentionLimitProtection test", function () { From a0a184643d0a85e32744039fa9e0192bfe5152cf Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 20 Jun 2025 14:52:28 +0100 Subject: [PATCH 2/7] Update MentionLimitProtection for new event mixins model. --- .../DraupnirProtectedRoomsSet.ts | 2 + src/protections/MentionLimitProtection.tsx | 105 ++++++++++++------ .../protections/MentionLimitProtectionTest.ts | 8 +- 3 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts index 498605d..070fc22 100644 --- a/src/draupnirfactory/DraupnirProtectedRoomsSet.ts +++ b/src/draupnirfactory/DraupnirProtectedRoomsSet.ts @@ -11,6 +11,7 @@ import { ActionResult, ClientPlatform, + DefaultMixinExtractor, LoggableConfigTracker, Logger, MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, @@ -219,6 +220,7 @@ export async function makeProtectedRoomsSet( protectedRoomsManager.ok, protectionsConfig.ok, userID, + DefaultMixinExtractor, makeHandleMissingProtectionPermissions( clientPlatform.toRoomMessageSender(), managementRoom.toRoomIDOrAlias() diff --git a/src/protections/MentionLimitProtection.tsx b/src/protections/MentionLimitProtection.tsx index 0eda009..83bc20f 100644 --- a/src/protections/MentionLimitProtection.tsx +++ b/src/protections/MentionLimitProtection.tsx @@ -5,16 +5,20 @@ import { AbstractProtection, + ContentMixins, EDStatic, EventConsequences, + EventWithMixins, Logger, - MediaMixinTypes, + MentionsMixin, + MentionsMixinDescription, + NewContentMixinDescription, Ok, ProtectedRoomsSet, Protection, ProtectionDescription, + RoomMessageBodyMixinDescription, RoomMessageSender, - SafeMediaEvent, Task, UserConsequences, describeProtection, @@ -36,29 +40,57 @@ import { Result } from "@gnuxie/typescript-result"; const log = new Logger("MentionLimitProtection"); -export function isContainingMentionsOverLimit( - event: SafeMediaEvent, +function isMentionsMixinOverLimit( + mentionsMixin: MentionsMixin, + maxMentions: number +): boolean { + return mentionsMixin.user_ids.length > maxMentions; +} + +function isContentContaningMentionsOverLimit( + content: ContentMixins, maxMentions: number, checkBody: boolean ): boolean { - const bodyMedia = event.media.filter( - (mixin) => mixin.mixinType === MediaMixinTypes.Body - ); - const mentionMedia = event.media.filter( - (mixin) => mixin.mixinType === MediaMixinTypes.Mentions - ); - const isOverLimit = (user_ids: string[]): boolean => - user_ids.length > maxMentions; - if (mentionMedia.some((mixin) => isOverLimit(mixin.user_ids))) { - return true; - } + const mentionMixin = content.findMixin(MentionsMixinDescription); if ( - checkBody && - bodyMedia.some((mixin) => mixin.body.split("@").length - 1 > maxMentions) + mentionMixin?.isErroneous === false && + isMentionsMixinOverLimit(mentionMixin, maxMentions) ) { return true; } - return false; + if (!checkBody) { + return false; + } + const bodyMixin = content.findMixin(RoomMessageBodyMixinDescription); + if (bodyMixin === undefined || bodyMixin.isErroneous) { + return false; + } + return bodyMixin.body.split("@").length - 1 > maxMentions; +} + +export function isContainingMentionsOverLimit( + event: EventWithMixins, + maxMentions: number, + checkBody: boolean +): boolean { + const isTopContentOverLimit = isContentContaningMentionsOverLimit( + event, + maxMentions, + checkBody + ); + if (isTopContentOverLimit) { + return true; + } + const newContentMixin = event.findMixin(NewContentMixinDescription); + if (newContentMixin === undefined || newContentMixin.isErroneous) { + return false; + } + return isContentContaningMentionsOverLimit( + newContentMixin, + maxMentions, + checkBody + ); } const MentionLimitProtectionSettings = Type.Object( @@ -121,8 +153,11 @@ export class MentionLimitProtection this.warningText = settings.warningText; this.includeLegacymentions = settings.includeLegacyMentions; } - public handleTimelineMedia(_room: MatrixRoomID, event: SafeMediaEvent): void { - if (event.sender === this.protectedRoomsSet.userID) { + public handleTimelineEventMixins( + _room: MatrixRoomID, + event: EventWithMixins + ): void { + if (event.sourceEvent.sender === this.protectedRoomsSet.userID) { return; } if ( @@ -139,17 +174,24 @@ export class MentionLimitProtection } public async handleEventOverLimit( - event: SafeMediaEvent + event: EventWithMixins ): Promise> { - const infractions = this.consequenceBucket.getTokenCount(event.sender); + const sourceEvent = event.sourceEvent; + const infractions = this.consequenceBucket.getTokenCount( + sourceEvent.sender + ); if (infractions > 0) { const userResult = await this.userConsequences.consequenceForUserInRoom( - event.room_id, - event.sender, + sourceEvent.room_id, + sourceEvent.sender, this.warningText ); if (isError(userResult)) { - log.error("Failed to ban the user", event.sender, userResult.error); + log.error( + "Failed to ban the user", + sourceEvent.sender, + userResult.error + ); } // fall through to the event consequence on purpose so we redact the event too. } else { @@ -157,21 +199,22 @@ export class MentionLimitProtection void Task( sendMatrixEventsFromDeadDocument( this.roomMessageSender, - event.room_id, + sourceEvent.room_id, - {renderMentionPill(event.sender, event.sender)} {this.warningText} + {renderMentionPill(sourceEvent.sender, sourceEvent.sender)}{" "} + {this.warningText} , - { replyToEvent: event.sourceEvent } + { replyToEvent: sourceEvent } ), { log, } ); } - this.consequenceBucket.addToken(event.sender); + this.consequenceBucket.addToken(sourceEvent.sender); return await this.eventConsequences.consequenceForEvent( - event.room_id, - event.event_id, + sourceEvent.room_id, + sourceEvent.event_id, this.warningText ); } diff --git a/test/unit/protections/MentionLimitProtectionTest.ts b/test/unit/protections/MentionLimitProtectionTest.ts index 02d953e..595d1c8 100644 --- a/test/unit/protections/MentionLimitProtectionTest.ts +++ b/test/unit/protections/MentionLimitProtectionTest.ts @@ -4,11 +4,11 @@ // SPDX-License-Identifier: Apache-2.0 import { - extractSafeMediaEvent, + DefaultMixinExtractor, + EventWithMixins, randomRoomID, randomUserID, RoomEvent, - SafeMediaEvent, } from "matrix-protection-suite"; import { isContainingMentionsOverLimit } from "../../../src/protections/MentionLimitProtection"; import expect from "expect"; @@ -17,8 +17,8 @@ function messageEvent(content: { body?: string; formatted_body?: string; "m.mentions"?: { user_ids: string[] }; -}): SafeMediaEvent { - return extractSafeMediaEvent({ +}): EventWithMixins { + return DefaultMixinExtractor.parseEvent({ content, type: "m.room.message", sender: randomUserID(), From 061670674798f6beb91e7f59b4d9376b31667433 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Fri, 20 Jun 2025 16:59:27 +0100 Subject: [PATCH 3/7] Add InvalidEventProtection to redact events with invalid mixins. We're quite liberal with our mixin definitions, so we redact events that don't conform because they could clearly be attempts to play with clients or protections that don't play nicely. --- .../DefaultEnabledProtectionsMigration.ts | 22 ++ src/protections/DraupnirProtectionsIndex.ts | 1 + src/protections/InvalidEventProtection.tsx | 214 ++++++++++++++++++ 3 files changed, 237 insertions(+) create mode 100644 src/protections/InvalidEventProtection.tsx diff --git a/src/protections/DefaultEnabledProtectionsMigration.ts b/src/protections/DefaultEnabledProtectionsMigration.ts index bff0bed..732ede4 100644 --- a/src/protections/DefaultEnabledProtectionsMigration.ts +++ b/src/protections/DefaultEnabledProtectionsMigration.ts @@ -18,6 +18,7 @@ import { RedactionSynchronisationProtection } from "./RedactionSynchronisation"; import { PolicyChangeNotification } from "./PolicyChangeNotification"; import { JoinRoomsOnInviteProtection } from "./invitation/JoinRoomsOnInviteProtection"; import { RoomsSetBehaviour } from "./ProtectedRooms/RoomsSetBehaviourProtection"; +import { InvalidEventProtection } from "./InvalidEventProtection"; export const DefaultEnabledProtectionsMigration = new SchemedDataManager([ @@ -165,4 +166,25 @@ export const DefaultEnabledProtectionsMigration = [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion, }); }, + async function enableInvalidEventProtection(input, toVersion) { + if (!Value.Check(MjolnirEnabledProtectionsEvent, input)) { + return ActionError.Result( + `The data for ${MjolnirEnabledProtectionsEventType} is corrupted.` + ); + } + const enabledProtections = new Set(input.enabled); + const protection = findProtection(InvalidEventProtection.name); + if (protection === undefined) { + const message = `Cannot find the ${RoomsSetBehaviour.name} protection`; + return ActionException.Result(message, { + exception: new TypeError(message), + exceptionKind: ActionExceptionKind.Unknown, + }); + } + enabledProtections.add(protection.name); + return Ok({ + enabled: [...enabledProtections], + [DRAUPNIR_SCHEMA_VERSION_KEY]: toVersion, + }); + }, ]); diff --git a/src/protections/DraupnirProtectionsIndex.ts b/src/protections/DraupnirProtectionsIndex.ts index ac14907..0a06249 100644 --- a/src/protections/DraupnirProtectionsIndex.ts +++ b/src/protections/DraupnirProtectionsIndex.ts @@ -16,6 +16,7 @@ import "./BanPropagation"; import "./BasicFlooding"; import "./FirstMessageIsImage"; import "./HomeserverUserPolicyApplication/HomeserverUserPolicyProtection"; +import "./InvalidEventProtection"; import "./JoinWaveShortCircuit"; import "./RedactionSynchronisation"; import "./MembershipChangeProtection"; diff --git a/src/protections/InvalidEventProtection.tsx b/src/protections/InvalidEventProtection.tsx new file mode 100644 index 0000000..1245b41 --- /dev/null +++ b/src/protections/InvalidEventProtection.tsx @@ -0,0 +1,214 @@ +// Copyright 2025 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 + +import { Type } from "@sinclair/typebox"; +import { + AbstractProtection, + describeProtection, + EDStatic, + EventConsequences, + EventWithMixins, + isError, + Logger, + Ok, + ProtectedRoomsSet, + Protection, + ProtectionDescription, + RoomMessageSender, + Task, + UserConsequences, +} from "matrix-protection-suite"; +import { Draupnir } from "../Draupnir"; +import { + MatrixRoomID, + StringRoomID, + StringUserID, +} from "@the-draupnir-project/matrix-basic-types"; +import { LazyLeakyBucket } from "../queues/LeakyBucket"; +import { DeadDocumentJSX } from "@the-draupnir-project/interface-manager"; +import { + renderMentionPill, + sendMatrixEventsFromDeadDocument, +} from "@the-draupnir-project/mps-interface-adaptor"; + +const log = new Logger("InvalidEventProtection"); + +const InvalidEventProtectionSettings = Type.Object( + { + warningText: Type.String({ + description: + "The reason to use to notify the user after redacting their infringing message.", + default: + "You have sent an invalid event that could cause problems in some Matrix clients, so we have had to redact it.", + }), + }, + { + title: "InvalidEventProtectionSettings", + } +); + +type InvalidEventProtectionSettings = EDStatic< + typeof InvalidEventProtectionSettings +>; + +export type InvalidEventProtectionDescription = ProtectionDescription< + unknown, + typeof InvalidEventProtectionSettings, + InvalidEventProtectionCapabilities +>; + +export type InvalidEventProtectionCapabilities = { + eventConsequences: EventConsequences; + userConsequences: UserConsequences; +}; + +export class InvalidEventProtection + extends AbstractProtection + implements Protection +{ + private readonly eventConsequences: EventConsequences; + private readonly userConsequences: UserConsequences; + private readonly consequenceBucket = new LazyLeakyBucket( + 1, + 30 * 60_000 // half an hour will do + ); + public constructor( + description: InvalidEventProtectionDescription, + capabilities: InvalidEventProtectionCapabilities, + protectedRoomsSet: ProtectedRoomsSet, + private readonly warningText: string, + private readonly roomMessageSender: RoomMessageSender, + private readonly managentRoomID: StringRoomID + ) { + super(description, capabilities, protectedRoomsSet, {}); + this.eventConsequences = capabilities.eventConsequences; + this.userConsequences = capabilities.userConsequences; + } + + private async redactEventWithMixin(event: EventWithMixins): Promise { + const redactResult = await this.eventConsequences.consequenceForEvent( + event.sourceEvent.room_id, + event.sourceEvent.event_id, + "invalid event mixin" + ); + if (isError(redactResult)) { + log.error( + `Failed to redact and event sent by ${event.sourceEvent.sender} in ${event.sourceEvent.room_id}`, + redactResult.error + ); + } + const managementRoomSendResult = await sendMatrixEventsFromDeadDocument( + this.roomMessageSender, + this.managentRoomID, + +
+ + Copy of invalid event content from {event.sourceEvent.sender} + +
{JSON.stringify(event.sourceEvent.content)}
+
+
, + {} + ); + if (isError(managementRoomSendResult)) { + log.error( + "Failed to send redacted event details to the management room", + managementRoomSendResult.error + ); + } + } + + private async sendWarning(event: EventWithMixins): Promise { + const result = await sendMatrixEventsFromDeadDocument( + this.roomMessageSender, + event.sourceEvent.room_id, + + {renderMentionPill(event.sourceEvent.sender, event.sourceEvent.sender)}{" "} + {this.warningText} + , + { replyToEvent: event.sourceEvent } + ); + if (isError(result)) { + log.error( + "Unable to warn the user", + event.sourceEvent.sender, + result.error + ); + } + } + + private async banUser(event: EventWithMixins): Promise { + const banResult = await this.userConsequences.consequenceForUserInRoom( + event.sourceEvent.room_id, + event.sourceEvent.sender, + "Sending invalid events" + ); + if (isError(banResult)) { + log.error( + "Unable to ban the sender of invalid events", + event.sourceEvent.sender, + event.sourceEvent.room_id, + banResult.error + ); + } + } + + public handleProtectionDisable(): void { + this.consequenceBucket.stop(); + } + + public handleTimelineEventMixins( + _room: MatrixRoomID, + event: EventWithMixins + ): void { + if (!event.mixins.some((mixin) => mixin.isErroneous)) { + return; + } + const infractions = this.consequenceBucket.getTokenCount( + event.sourceEvent.sender + ); + if (infractions > 0) { + void Task(this.banUser(event), { log }); + } else { + void Task(this.sendWarning(event), { log }); + } + this.consequenceBucket.addToken(event.sourceEvent.sender); + void Task(this.redactEventWithMixin(event), { log }); + } +} + +describeProtection< + InvalidEventProtectionCapabilities, + Draupnir, + typeof InvalidEventProtectionSettings +>({ + name: "InvalidEventProtection", + description: `Protect the room against malicious events or evasion of other protections.`, + capabilityInterfaces: { + eventConsequences: "EventConsequences", + userConsequences: "UserConsequences", + }, + defaultCapabilities: { + eventConsequences: "StandardEventConsequences", + userConsequences: "StandardUserConsequences", + }, + configSchema: InvalidEventProtectionSettings, + factory: async ( + decription, + protectedRoomsSet, + draupnir, + capabilitySet, + settings + ) => + Ok( + new InvalidEventProtection( + decription, + capabilitySet, + protectedRoomsSet, + settings.warningText, + draupnir.clientPlatform.toRoomMessageSender(), + draupnir.managementRoomID + ) + ), +}); From 05129ed855e10b2788d2fed8efcd616fa8b41ff2 Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 23 Jun 2025 13:18:54 +0100 Subject: [PATCH 4/7] Update WordList protection for new event mixin model. DRP-003. --- src/protections/WordList.ts | 138 ++++++++++++++++++++---------------- 1 file changed, 76 insertions(+), 62 deletions(-) diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index afec55c..ec875b1 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -12,6 +12,8 @@ import { AbstractProtection, ActionResult, EventConsequences, + EventWithMixins, + ExtensibleTextMixinDescription, Logger, MembershipChange, MembershipChangeType, @@ -19,12 +21,12 @@ import { ProtectedRoomsSet, Protection, ProtectionDescription, - RoomEvent, RoomMembershipRevision, - RoomMessage, + RoomMessageBodyMixinDescription, + RoomMessageFormattedBodyMixinDescription, + Task, UnknownConfig, UserConsequences, - Value, describeProtection, isError, } from "matrix-protection-suite"; @@ -90,7 +92,7 @@ export class WordListProtection implements Protection { private justJoined: JustJoinedByRoom = new Map(); - private badWords?: RegExp; + private badWords: RegExp; private readonly userConsequences: UserConsequences; private readonly eventConsequences: EventConsequences; @@ -103,6 +105,15 @@ export class WordListProtection super(description, capabilities, protectedRoomsSet, {}); this.userConsequences = capabilities.userConsequences; this.eventConsequences = capabilities.eventConsequences; + // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + const escapeRegExp = (string: string) => { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }; + // Create a mega-regex from all the tiny words. + const words = this.draupnir.config.protections.wordlist.words + .filter((word) => word.length !== 0) + .map(escapeRegExp); + this.badWords = new RegExp(words.join("|"), "i"); } public async handleMembershipChange( revision: RoomMembershipRevision, @@ -136,90 +147,93 @@ export class WordListProtection return Ok(undefined); } - public async handleTimelineEvent( + public handleTimelineEventMixins( room: MatrixRoomID, - event: RoomEvent - ): Promise> { + event: EventWithMixins + ): void { // If the sender is draupnir, ignore the message - if (event["sender"] === this.draupnir.clientUserID) { - log.debug(`Ignoring message from self: ${event.event_id}`); - return Ok(undefined); + if (event.sourceEvent["sender"] === this.draupnir.clientUserID) { + log.debug(`Ignoring message from self: ${event.sourceEvent.event_id}`); + return; + } + const bodies = [ + ((mixin) => (mixin?.isErroneous === true ? undefined : mixin?.body))( + event.findMixin(RoomMessageBodyMixinDescription) + ), + ((mixin) => (mixin?.isErroneous ? undefined : mixin?.formatted_body))( + event.findMixin(RoomMessageFormattedBodyMixinDescription) + ), + ...(((mixin) => + mixin?.isErroneous + ? undefined + : mixin?.representations.map( + (representation) => representation.body + ))(event.findMixin(ExtensibleTextMixinDescription)) ?? []), + ].filter((mixin) => mixin !== undefined); + if (bodies.length === 0) { + return; } const minsBeforeTrusting = this.draupnir.config.protections.wordlist.minutesBeforeTrusting; - if (Value.Check(RoomMessage, event)) { - if (!("msgtype" in event.content)) { - return Ok(undefined); - } - const message = - ("formatted_body" in event.content && - event.content["formatted_body"]) || - event.content["body"]; - const roomID = room.toRoomIDOrAlias(); + const roomID = room.toRoomIDOrAlias(); + // Check conditions first + if (minsBeforeTrusting > 0) { + const roomEntry = this.justJoined.get(roomID); + const joinTime = roomEntry?.get(event.sourceEvent["sender"]); + if (joinTime !== undefined) { + // Disregard if the user isn't recently joined - // Check conditions first - if (minsBeforeTrusting > 0) { - const roomEntry = this.justJoined.get(roomID); - const joinTime = roomEntry?.get(event["sender"]); - if (joinTime !== undefined) { - // Disregard if the user isn't recently joined - - // Check if they did join recently, was it within the timeframe - const now = new Date(); - if ( - now.valueOf() - joinTime.valueOf() > - minsBeforeTrusting * 60 * 1000 - ) { - roomEntry?.delete(event["sender"]); // Remove the user - log.info(`${event["sender"]} is no longer considered suspect`); - return Ok(undefined); - } - } else { - // The user isn't in the recently joined users list, no need to keep - // looking - return Ok(undefined); + // Check if they did join recently, was it within the timeframe + const now = new Date(); + if ( + now.valueOf() - joinTime.valueOf() > + minsBeforeTrusting * 60 * 1000 + ) { + roomEntry?.delete(event.sourceEvent["sender"]); // Remove the user + log.info( + `${event.sourceEvent["sender"]} is no longer considered suspect` + ); + return; } + } else { + // The user isn't in the recently joined users list, no need to keep + // looking + return; } - if (!this.badWords) { - // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping - const escapeRegExp = (string: string) => { - return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - }; - - // Create a mega-regex from all the tiny words. - const words = this.draupnir.config.protections.wordlist.words - .filter((word) => word.length !== 0) - .map(escapeRegExp); - this.badWords = new RegExp(words.join("|"), "i"); - } - - const match = this.badWords.exec(message); - if (match) { - const reason = `Said a bad word. Moderators, consult the management room for more information.`; + } + const match = bodies.find((body) => this.badWords.exec(body)); + if (!match) { + return; + } + const reason = `Said a bad word. Moderators, consult the management room for more information.`; + void Task( + (async () => { await this.userConsequences.consequenceForUserInRoom( roomID, - event.sender, + event.sourceEvent.sender, reason ); const messageResult = await this.draupnir.client .sendMessage(this.draupnir.managementRoomID, { msgtype: "m.notice", - body: `Banned ${event.sender} in ${roomID} for saying '${match[0]}'.`, + body: `Banned ${event.sourceEvent.sender} in ${roomID} for saying '${match[0]}'.`, }) .then((_) => Ok(undefined), resultifyBotSDKRequestError); if (isError(messageResult)) { log.error( - `Failed to send a message to the management room after banning ${event.sender} in ${roomID} for saying '${match[0]}'.`, + `Failed to send a message to the management room after banning ${event.sourceEvent.sender} in ${roomID} for saying '${match[0]}'.`, messageResult.error ); } await this.eventConsequences.consequenceForEvent( roomID, - event.event_id, + event.sourceEvent.event_id, reason ); + })(), + { + log, } - } - return Ok(undefined); + ); } } From d61e225f252d19fb3e11cf1ffa543720719dcf9e Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 23 Jun 2025 14:28:36 +0100 Subject: [PATCH 5/7] Elaborate on how WordList protection works. --- src/protections/WordList.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/protections/WordList.ts b/src/protections/WordList.ts index ec875b1..b3cfb70 100644 --- a/src/protections/WordList.ts +++ b/src/protections/WordList.ts @@ -57,7 +57,8 @@ describeProtection({ name: "WordListProtection", description: "If a user posts a monitored word a set amount of time after joining, they\ - will be banned from that room. This will not publish the ban to a ban list.", + will be banned from that room. This will not publish the ban to a ban list.\ + This protection only targets recently joined users.", capabilityInterfaces: { userConsequences: "UserConsequences", eventConsequences: "EventConsequences", From d1f8f445ae62eb8dc5cace1436bcd2a82f03911f Mon Sep 17 00:00:00 2001 From: gnuxie Date: Mon, 23 Jun 2025 15:54:03 +0100 Subject: [PATCH 6/7] Update to MPS 3.7.1 for new mixin extraction API. --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 53de23a..8194d31 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "jsdom": "^24.0.0", "matrix-appservice-bridge": "^10.3.1", "matrix-bot-sdk": "npm:@vector-im/matrix-bot-sdk@^0.7.1-element.6", - "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.6.2", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@3.7.1", "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@3.6.6", "pg": "^8.8.0", "yaml": "^2.3.2" diff --git a/yarn.lock b/yarn.lock index 91f8bdb..7e406df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2606,10 +2606,10 @@ matrix-appservice@^2.0.0: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2" -"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.6.2": - version "3.6.2" - resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.6.2.tgz#7d62433d859156ab1bd288c2813c94d4e04c2de7" - integrity sha512-isXsyZn5EH4V2tyhYx1Mf+L5GfQC7/AdBxoZc7fk5hCsEjWaKj6VxpNoSmhx0Ev3F7zr9I52vRN7hNutumwP6g== +"matrix-protection-suite@npm:@gnuxie/matrix-protection-suite@3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-3.7.1.tgz#0f5e128bb05a087b0f144f5466e648c89d88a50d" + integrity sha512-awSsgCqcWtPfNKOHM76Sb5w8UFpB1JDSy5vI5GXWOL2tFl7kXdZ9QXc0ohPC31+jXsKZgXOBaGMY3z830p+DVw== dependencies: "@gnuxie/typescript-result" "^1.0.0" await-lock "^2.2.2" From 2ad39d9dbd5274614b86b06b4885c17d1d33b4dc Mon Sep 17 00:00:00 2001 From: gnuxie Date: Tue, 24 Jun 2025 11:40:24 +0100 Subject: [PATCH 7/7] Ensure appservice calls stop on draupnir. This is now apparent because the protection that deletes events with erroneous mixins uses a lazy leaky bucket which has a timeout that needs cancelling. This will probably fix a few unexplained issues as a side effect. --- src/appservice/AppService.ts | 1 + src/appservice/AppServiceDraupnirManager.ts | 4 ++++ .../StandardDraupnirManager.ts | 20 ++++++++++++++++--- src/protections/MentionLimitProtection.tsx | 4 ++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/appservice/AppService.ts b/src/appservice/AppService.ts index 8c74148..151adb0 100644 --- a/src/appservice/AppService.ts +++ b/src/appservice/AppService.ts @@ -382,6 +382,7 @@ export class MjolnirAppService { await this.bridge.close(); await this.dataStore.close(); await this.api.close(); + this.draupnirManager.unregisterListeners(); } /** diff --git a/src/appservice/AppServiceDraupnirManager.ts b/src/appservice/AppServiceDraupnirManager.ts index ad13d89..c29b650 100644 --- a/src/appservice/AppServiceDraupnirManager.ts +++ b/src/appservice/AppServiceDraupnirManager.ts @@ -83,6 +83,10 @@ export class AppServiceDraupnirManager { return `@${mjolnirRecord.local_part}:${this.serverName}` as StringUserID; } + public unregisterListeners(): void { + this.baseManager.unregisterListeners(); + } + /** * Create the draupnir manager from the datastore and the access control. * @param dataStore The data store interface that has the details for provisioned draupnirs. diff --git a/src/draupnirfactory/StandardDraupnirManager.ts b/src/draupnirfactory/StandardDraupnirManager.ts index adb17b8..405d4c9 100644 --- a/src/draupnirfactory/StandardDraupnirManager.ts +++ b/src/draupnirfactory/StandardDraupnirManager.ts @@ -42,6 +42,15 @@ export class StandardDraupnirManager { // nothing to do. } + public unregisterListeners(): void { + for (const [, draupnir] of this.draupnir) { + draupnir.stop(); + } + for (const [, safeModeDraupnir] of this.safeModeDraupnir) { + safeModeDraupnir.stop(); + } + } + public makeSafeModeToggle( clientUserID: StringUserID, managementRoom: MatrixRoomID, @@ -52,6 +61,7 @@ export class StandardDraupnirManager { const draupnirManager = this; const toggle: SafeModeToggle = Object.freeze({ async switchToSafeMode(cause: SafeModeCause) { + draupnirManager.stopDraupnir(clientUserID); return draupnirManager.makeSafeModeDraupnir( clientUserID, managementRoom, @@ -60,6 +70,7 @@ export class StandardDraupnirManager { ); }, async switchToDraupnir() { + draupnirManager.stopDraupnir(clientUserID); return draupnirManager.makeDraupnir( clientUserID, managementRoom, @@ -216,12 +227,15 @@ export class StandardDraupnirManager { public stopDraupnir(clientUserID: StringUserID): void { const draupnir = this.draupnir.get(clientUserID); - if (draupnir === undefined) { - return; - } else { + if (draupnir !== undefined) { draupnir.stop(); this.draupnir.delete(clientUserID); } + const safeModeDraupnir = this.safeModeDraupnir.get(clientUserID); + if (safeModeDraupnir) { + safeModeDraupnir.stop(); + this.safeModeDraupnir.delete(clientUserID); + } } } diff --git a/src/protections/MentionLimitProtection.tsx b/src/protections/MentionLimitProtection.tsx index 83bc20f..f3e5be2 100644 --- a/src/protections/MentionLimitProtection.tsx +++ b/src/protections/MentionLimitProtection.tsx @@ -218,6 +218,10 @@ export class MentionLimitProtection this.warningText ); } + + handleProtectionDisable(): void { + this.consequenceBucket.stop(); + } } export type MentionLimitProtectionCapabilities = {