-
Notifications
You must be signed in to change notification settings - Fork 57
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
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks a lot for this PR!
Seems like my vscode likes to reformat stuff differently somehow :/ I can revert the unrelated formatting changes if needed. |
a0e913d
to
b7bdb05
Compare
Oops, looks like CI was waiting for approval before launching. Let's see how it goes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
big thank for the PR
can we merge it please? i really need this protection :D |
@MTRNord Just back from trip, I'll try and review this today or tomorrow. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PR looks good, could you add a test?
@MTRNord I realize I wasn't clear: when I write "test", I mean a new integration test, see e.g. |
…ling due to pills
…s pills and also dont fail on null being returned by String.prototype.match
d337ff4
to
219ecce
Compare
@MTRNord What's the status of this PR? |
Sorry, I nearly have the tests done, but there is some small thing I still need to do. Just didn't get to it yet due to other work related tasks that took higher priority. Want to do the rest of the code on the weekend. |
Don't hesitate to yell if you need help! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Made some observations about the test, you don't need to act on them but they're just things that would keep them cleaner this or next time
} finally { | ||
await moderator.stop(); | ||
await userA.stop(); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
doesn't matter too much but since you stop these clients in the afterEach hook you should be able to take out these try/finally
blocks
const userA = await newTestUser({ name: { contains: "a" } }); | ||
|
||
this.moderator = moderator; | ||
this.userA = userA; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nitpick but userA
doesn't tell us what this use is doing. they could unrelated to moderation of the room, they could just be a participant, they could even be a user who is going to be spamming, who is userA
?
import { MatrixClient } from "matrix-bot-sdk"; | ||
|
||
/** | ||
* Returns a promise that resolves to the first event while sending events. | ||
* @param client A MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. | ||
* This function assumes that the start() has already been called on the client. | ||
* @param targetRoom The room to listen for the reply in. | ||
* @param produceEvents A function that produces an events when called. | ||
* @returns The first event. | ||
*/ | ||
export async function getFirstMessage(client: MatrixClient, targetRoom: string, produceEvents: () => Promise<any>): Promise<any> { | ||
let reactionEvents = []; | ||
const addEvent = function (roomId, event) { | ||
if (roomId !== targetRoom) return; | ||
if (event.type !== 'm.room.message') return; | ||
reactionEvents.push(event); | ||
}; | ||
let targetCb; | ||
try { | ||
client.on('room.event', addEvent); | ||
await produceEvents(); | ||
for (let event of reactionEvents) { | ||
return event; | ||
} | ||
return await new Promise(resolve => { | ||
targetCb = function (roomId, event) { | ||
if (roomId !== targetRoom) return; | ||
if (event.type !== 'm.room.message') return; | ||
resolve(event); | ||
}; | ||
client.on('room.event', targetCb); | ||
}); | ||
} finally { | ||
client.removeListener('room.event', addEvent); | ||
if (targetCb) { | ||
client.removeListener('room.event', targetCb); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* Returns true if the user got changed to the wanted membership type. | ||
* @param client A MatrixClient that is already in the targetRoom. We will use it to listen for the event produced by targetEventThunk. | ||
* This function assumes that the start() has already been called on the client. | ||
* @param targetRoom The room to listen for the reply in. | ||
* @param userId The user that should get checked for. | ||
* @param membership The membership type that the user should get. | ||
* @returns The first event. | ||
*/ | ||
export async function checkMembershipChange(client: MatrixClient, targetRoom: string, userId: string, membership: string): Promise<boolean> { | ||
let membershipEvents = []; | ||
const addEvent = function (roomId, event) { | ||
if (roomId !== targetRoom) return; | ||
if (event.type !== 'm.room.member') return; | ||
membershipEvents.push(event); | ||
}; | ||
let targetCb; | ||
try { | ||
client.on('room.event', addEvent); | ||
for (let event of membershipEvents) { | ||
if (event.state_key == userId && event.content.membership == membership) { | ||
return true; | ||
} else if (event.state_key == userId && event.content.membership != membership) { | ||
return false; | ||
} | ||
} | ||
return await new Promise(resolve => { | ||
targetCb = function (roomId, event) { | ||
if (roomId !== targetRoom) return; | ||
if (event.type !== 'm.room.member') return; | ||
if (event.state_key == userId && event.content.membership == membership) { | ||
resolve(true); | ||
} else if (event.state_key == userId && event.content.membership != membership) { | ||
resolve(false); | ||
} | ||
}; | ||
client.on('room.event', targetCb); | ||
}); | ||
} finally { | ||
client.removeListener('room.event', addEvent); | ||
if (targetCb) { | ||
client.removeListener('room.event', targetCb); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know it's not your fault/related to this PR necessarily, but i am a little worried now about how this pattern is being used everywhere https://github.com/MTRNord/mjolnir/blob/MTRNord/mention-flood/test/integration/commands/commandUtils.ts#L5-L97
Sorry for the delay, I'm just back from vacation, I'll try and review this today. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quite good!
You'll need a few more changes, but we're getting there.
const DEFAULT_ACTION = "ban"; | ||
|
||
const LOCALPART_REGEX = "[0-9a-z-.=_/]+"; | ||
// https://github.com/johno/domain-regex/blob/8a6984c8fa1fe8481a4b99be0fa7f2a01ee17517/index.js |
There was a problem hiding this comment.
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 LOCALPART_REGEX = "[0-9a-z-.=_/]+"; | ||
// https://github.com/johno/domain-regex/blob/8a6984c8fa1fe8481a4b99be0fa7f2a01ee17517/index.js | ||
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 |
There was a problem hiding this comment.
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.
import { htmlEscape } from "../utils"; | ||
import { BooleanProtectionSetting, DurationMSProtectionSetting, NumberProtectionSetting, OptionListProtectionSetting } from "./ProtectionSettings"; | ||
|
||
const DEFAULT_MINUTES_BEFORE_TRUSTING = 20 * 60 * 1000; |
There was a problem hiding this comment.
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.
|
||
settings = { | ||
// Time in which this protection takes action on users | ||
minutesBeforeTrusting: new DurationMSProtectionSetting(DEFAULT_MINUTES_BEFORE_TRUSTING), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely not minutes.
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. |
There was a problem hiding this comment.
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 (event['type'] === 'm.room.message') { | ||
const message: string = content['formatted_body'] || content['body'] || ""; | ||
|
||
// Check conditions first |
There was a problem hiding this comment.
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?
const content = event['content'] || {}; | ||
const minsBeforeTrusting = this.settings.minutesBeforeTrusting.value; | ||
|
||
if (event['type'] === 'm.room.message') { |
There was a problem hiding this comment.
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;
}
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir enable MentionFlood` }); | ||
}); | ||
} finally { | ||
await moderator.stop(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unfortunately, there are cases in which this finally
will not be executed. In particular, in case of timeout, or if the call to joinRoom
fails for some reason. This causes massive breakages in our test suite.
Instead of this finally
, you need to declare the moderator as:
this.moderator = await newTester(...)
And add
afterEach(async function() {
await this.moderator?.stop();
// also, any other client, e.g. `userA`, `userB`
});
towards the beginning or end of this describe
.
// Create users | ||
const mjolnir = config.RUNTIME.client!; | ||
const mjolnirUserId = await mjolnir.getUserId(); | ||
const moderator = await newTestUser({ name: { contains: "moderator" } }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here, too, turn this into this.moderator = ...
and get rid of the finally
.
client.removeListener('room.event', targetCb); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Missing newline.
This basically tries to solve the current way of spam.
This is an adapted wordfilter protection. At the time of writing, this is not tested anywhere.
It only matches mxid based mentions currently. Not other mentions per default push rules.
TODO
coreharbor.kubernetes.midnightthoughts.space/matrixdotorg/mjolnir:mentionflood-protection
)Signed-off-by: Marcel Radzio <mtrnord[at]nordgedanken.dev>