Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Protection against mention flooding #210

Closed
wants to merge 21 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1309426
Add Protection against mention flooding
MTRNord Feb 6, 2022
edba358
Move settings to the class settings object
MTRNord Feb 6, 2022
e7daad8
Make justJoined a Map
MTRNord Feb 6, 2022
526e333
Add redact and action settings
MTRNord Feb 6, 2022
ea1f908
Make sure to escape the event_id data
MTRNord Feb 6, 2022
09f1dc7
Fix escaping the correct values in log messages
MTRNord Feb 6, 2022
7727108
Fix regex and make sure we dont use the formatted body to prevent fai…
MTRNord Feb 6, 2022
25de265
Revert previous changes to config.ts
MTRNord Feb 6, 2022
28b278b
Make sure to default run on formatted_body due to how element-web doe…
MTRNord Feb 6, 2022
8822bc1
Add function to the warning action
MTRNord Feb 6, 2022
01e53f4
Make sure to correctly escape the regex
MTRNord Feb 6, 2022
8d4bd29
Remove historic mxid regex part
MTRNord Feb 6, 2022
fd755c1
Update MentionFlood protection to work with 813741c42ca2a66cf02a84989…
MTRNord Feb 7, 2022
6483e39
Make sure we dont assume false on invalid input
MTRNord Feb 22, 2022
433a17a
Stricter type action by adding a setting that only allows predefined …
MTRNord Feb 22, 2022
6993f96
Apply removal of LogProxy to MentionFlood Protection
MTRNord Feb 28, 2022
219ecce
Use DurationMSProtectionSetting and add setting comments
MTRNord Mar 8, 2022
2575703
Move to using mjolnir.roomJoins.getUserJoin and fix missuse of DEFAUL…
MTRNord Mar 8, 2022
c197cdf
Update MentionFlood description text
MTRNord Mar 8, 2022
ea9d586
Add missing newline
MTRNord Mar 8, 2022
1f1ba03
Add tests for mention flooding
MTRNord Apr 13, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions src/protections/MentionFlood.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
Copyright 2020 Emi Tatsuo Simpson et al.
Copyright 2022 Marcel Radzio

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { Protection } from "./IProtection";
import { Mjolnir } from "../Mjolnir";
import { LogLevel, LogService } from "matrix-bot-sdk";
import config from "../config";
import { htmlEscape } from "../utils";
import { BooleanProtectionSetting, DurationMSProtectionSetting, NumberProtectionSetting, OptionListProtectionSetting } from "./ProtectionSettings";

const DEFAULT_MINUTES_BEFORE_TRUSTING = 20 * 60 * 1000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Doesn't look like minutes to me.

const DEFAULT_MAX_MENTIONS_PER_MESSAGE = 20;
const DEFAULT_REDACT = true;
const DEFAULT_ACTION = "ban";

const LOCALPART_REGEX = "[0-9a-z-.=_/]+";
// https://github.com/johno/domain-regex/blob/8a6984c8fa1fe8481a4b99be0fa7f2a01ee17517/index.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Could you clarify this comment?

e.g.

/**
 * A regex to extract a domain name.
 * Based on https:// ...
 */

Same things below.

const DOMAIN_REGEX = "(\\b((?=[a-z0-9-]{1,63}\\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\\.)+[a-z]{2,63}\\b)";
// https://stackoverflow.com/a/5284410
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: one line between the various const would help with readability.

const IPV4_REGEX = "(((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))";
// https://stackoverflow.com/a/17871737
const IPV6_REGEX = "(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))";
const PORT_REGEX = "(:[0-9]+)?";


export class MentionFlood extends Protection {

settings = {
// Time in which this protection takes action on users
minutesBeforeTrusting: new DurationMSProtectionSetting(DEFAULT_MINUTES_BEFORE_TRUSTING),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely not minutes.

// The minimum amount of mentions for this protection to take action
maxMentionsPerMessage: new NumberProtectionSetting(DEFAULT_MAX_MENTIONS_PER_MESSAGE),
// Defines if messages shall also get directly redacted or not
redact: new BooleanProtectionSetting(DEFAULT_REDACT),
// The action that is supposed to get taken
action: new OptionListProtectionSetting(["ban", "kick", "warn"])
};

private mention: RegExp;

constructor() {
super();
this.mention = new RegExp(`@${LOCALPART_REGEX}:(${DOMAIN_REGEX}|${IPV4_REGEX}|${IPV6_REGEX})${PORT_REGEX}`, "gi");
}

public get name(): string {
return 'MentionFlood';
}

public get description(): string {
return `Protects against recently joined users attempting to ping too many other users at the same time.
This will not publish bans to the ban list.`;
}

public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
const content = event['content'] || {};
const minsBeforeTrusting = this.settings.minutesBeforeTrusting.value;

if (event['type'] === 'm.room.message') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think it would be a little more readable if you did the opposite, e.g.

if (event['type'] !== 'm.room.message') {
  return;
}

const message: string = content['formatted_body'] || content['body'] || "";

// Check conditions first
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Could you mention that we check conditions first because it's fast?

if (minsBeforeTrusting > 0) {
const joinTime = mjolnir.roomJoins.getUserJoin({ roomId: roomId, userId: event['sender'] });
// If we know the user and have its time we check if.
// Otherwise we assume a bug and still mark them as suspect just to make sure.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that everybody who joined before the start of Mjölnir is considered suspect. I believe that you should rather take the opposite policy and assume that if we don't have a joinTime, the user has been there for a long time.

if (joinTime) {

// Check if they did join recently, was it within the timeframe
const now = Date.now();
if (now.valueOf() - joinTime.valueOf() > minsBeforeTrusting) {
LogService.info("MentionFlood", `${htmlEscape(event['sender'])} is no longer considered suspect`);
return;
}

}
}


// Perform the test
const maxMentionsPerMessage = this.settings.maxMentionsPerMessage.value;
if (message && (message.match(this.mention)?.length || 0) > maxMentionsPerMessage) {
const action = this.settings.action.value !== "" ? this.settings.action.value : DEFAULT_ACTION;
switch (action) {
case "ban": {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `Banning ${htmlEscape(event['sender'])} for mention flood violation in ${roomId}.`);
if (!config.noop) {
await mjolnir.client.banUser(event['sender'], roomId, "Mention Flood violation");
} else {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `Tried to ban ${htmlEscape(event['sender'])} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
break;
}
case "kick": {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `Kicking ${htmlEscape(event['sender'])} for mention flood violation in ${roomId}.`);
if (!config.noop) {
await mjolnir.client.kickUser(event['sender'], roomId, "Mention Flood violation");
} else {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `Tried to kick ${htmlEscape(event['sender'])} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
break;
}
case "warn": {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `${htmlEscape(event['sender'])} triggered the mention flood protection in ${roomId}.`);
break;
}
}


// Redact the event
if (!config.noop && this.settings.redact.value) {
await mjolnir.client.redactEvent(roomId, event['event_id'], "spam");
} else {
await mjolnir.logMessage(LogLevel.WARN, "MentionFlood", `Tried to redact ${htmlEscape(event['event_id'])} in ${roomId} but Mjolnir is running in no-op mode`, roomId);
}
}
}
}
}
67 changes: 54 additions & 13 deletions src/protections/ProtectionSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ parseDuration["weeks"] = parseDuration["week"] = parseDuration["wk"];
parseDuration["months"] = parseDuration["month"];
parseDuration["years"] = parseDuration["year"];

export class ProtectionSettingValidationError extends Error {};
export class ProtectionSettingValidationError extends Error { };

/*
* @param TChange Type for individual pieces of data (e.g. `string`)
* @param TValue Type for overall value of this setting (e.g. `string[]`)
*/
export class AbstractProtectionSetting<TChange, TValue> extends EventEmitter {
// the current value of this setting
value: TValue
value: TValue;

/*
* Deserialise a value for this setting type from a string
Expand Down Expand Up @@ -106,7 +106,7 @@ export class StringListProtectionSetting extends AbstractProtectionListSetting<s
}
removeValue(data: string): string[] {
this.emit("remove", data);
this.value = this.value.filter(i => i !== data);
this.value = this.value.filter(i => i !== data);
return this.value;
}
}
Expand Down Expand Up @@ -134,13 +134,13 @@ export class MXIDListProtectionSetting extends StringListProtectionSetting {
}

export class NumberProtectionSetting extends AbstractProtectionSetting<number, number> {
min: number|undefined;
max: number|undefined;
min: number | undefined;
max: number | undefined;

constructor(
defaultValue: number,
min: number|undefined = undefined,
max: number|undefined = undefined
defaultValue: number,
min: number | undefined = undefined,
max: number | undefined = undefined
) {
super();
this.setValue(defaultValue);
Expand All @@ -155,7 +155,7 @@ export class NumberProtectionSetting extends AbstractProtectionSetting<number, n
validate(data: number) {
return (!isNaN(data)
&& (this.min === undefined || this.min <= data)
&& (this.max === undefined || data <= this.max))
&& (this.max === undefined || data <= this.max));
}
}

Expand All @@ -166,9 +166,9 @@ export class NumberProtectionSetting extends AbstractProtectionSetting<number, n
*/
export class DurationMSProtectionSetting extends AbstractProtectionSetting<number, number> {
constructor(
defaultValue: number,
public readonly minMS: number|undefined = undefined,
public readonly maxMS: number|undefined = undefined
defaultValue: number,
public readonly minMS: number | undefined = undefined,
public readonly maxMS: number | undefined = undefined
) {
super();
this.setValue(defaultValue);
Expand All @@ -181,6 +181,47 @@ export class DurationMSProtectionSetting extends AbstractProtectionSetting<numbe
validate(data: number) {
return (!isNaN(data)
&& (this.minMS === undefined || this.minMS <= data)
&& (this.maxMS === undefined || data <= this.maxMS))
&& (this.maxMS === undefined || data <= this.maxMS));
}
}

// A setting that only can be true or false.
// Returns undefined if anything else is set.
export class BooleanProtectionSetting extends AbstractProtectionSetting<boolean, boolean> {
constructor(
defaultValue: boolean
) {
super();
this.setValue(defaultValue);
}

// We return undefined for anything other than "true" or "false"
fromString(data: string) {
if (data.toLowerCase() === "true") {
return true;
} else if (data.toLowerCase() === "false") {
return false;
} else {
return undefined;
}
}

// This is already handled in setProtectionSettings due to the types
validate = (data: boolean): boolean => true;
}


// A list of strings that match the allowed values
export class OptionListProtectionSetting extends AbstractProtectionSetting<string, string> {
constructor(private allowedValues: string[]) {
super();
}

// We dont need to convert the string
fromString(data: string) {
return data;
}

// validates if data is in the allowedValues
validate = (data: string) => this.allowedValues.includes(data);
}
2 changes: 2 additions & 0 deletions src/protections/protections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { WordList } from "./WordList";
import { MessageIsVoice } from "./MessageIsVoice";
import { MessageIsMedia } from "./MessageIsMedia";
import { TrustedReporters } from "./TrustedReporters";
import { MentionFlood } from "./MentionFlood";

export const PROTECTIONS: Protection[] = [
new FirstMessageIsImage(),
Expand All @@ -31,4 +32,5 @@ export const PROTECTIONS: Protection[] = [
new MessageIsMedia(),
new TrustedReporters(),
new DetectFederationLag(),
new MentionFlood(),
];
Loading