mirror of
https://github.com/matrix-org/mjolnir.git
synced 2024-09-29 20:56:23 +00:00
Merge remote-tracking branch 'origin/main' into hs/native-e2e
This commit is contained in:
commit
e6083c310f
40
README.md
40
README.md
@ -82,6 +82,8 @@ set up:
|
||||
|
||||
## Synapse Module
|
||||
|
||||
**This requires Synapse 1.37.0 or higher**
|
||||
|
||||
Using the bot to manage your rooms is great, however if you want to use your ban lists
|
||||
(or someone else's) on your server to affect all of your users then a Synapse module
|
||||
is needed. Primarily meant to block invites from undesired homeservers/users, Mjolnir's
|
||||
@ -97,25 +99,25 @@ pip install -e "git+https://github.com/matrix-org/mjolnir.git#egg=mjolnir&subdir
|
||||
|
||||
Then add the following to your `homeserver.yaml`:
|
||||
```yaml
|
||||
spam_checker:
|
||||
module: mjolnir.AntiSpam
|
||||
config:
|
||||
# Prevent servers/users in the ban lists from inviting users on this
|
||||
# server to rooms. Default true.
|
||||
block_invites: true
|
||||
# Flag messages sent by servers/users in the ban lists as spam. Currently
|
||||
# this means that spammy messages will appear as empty to users. Default
|
||||
# false.
|
||||
block_messages: false
|
||||
# Remove users from the user directory search by filtering matrix IDs and
|
||||
# display names by the entries in the user ban list. Default false.
|
||||
block_usernames: false
|
||||
# The room IDs of the ban lists to honour. Unlike other parts of Mjolnir,
|
||||
# this list cannot be room aliases or permalinks. This server is expected
|
||||
# to already be joined to the room - Mjolnir will not automatically join
|
||||
# these rooms.
|
||||
ban_lists:
|
||||
- "!roomid:example.org"
|
||||
modules:
|
||||
- module: mjolnir.Module
|
||||
config:
|
||||
# Prevent servers/users in the ban lists from inviting users on this
|
||||
# server to rooms. Default true.
|
||||
block_invites: true
|
||||
# Flag messages sent by servers/users in the ban lists as spam. Currently
|
||||
# this means that spammy messages will appear as empty to users. Default
|
||||
# false.
|
||||
block_messages: false
|
||||
# Remove users from the user directory search by filtering matrix IDs and
|
||||
# display names by the entries in the user ban list. Default false.
|
||||
block_usernames: false
|
||||
# The room IDs of the ban lists to honour. Unlike other parts of Mjolnir,
|
||||
# this list cannot be room aliases or permalinks. This server is expected
|
||||
# to already be joined to the room - Mjolnir will not automatically join
|
||||
# these rooms.
|
||||
ban_lists:
|
||||
- "!roomid:example.org"
|
||||
```
|
||||
|
||||
*Note*: Although this is described as a "spam checker", it does much more than fight
|
||||
|
@ -74,8 +74,8 @@ noop: false
|
||||
|
||||
# Set to true to use /joined_members instead of /state to figure out who is
|
||||
# in the room. Using /state is preferred because it means that users are
|
||||
# banned when they are invited instead of just when they join, though if your
|
||||
# server struggles with /state requests then set this to true.
|
||||
# banned when they are invited instead of just when they join. Set this to true
|
||||
# if the bot is in large rooms or dozens of rooms.
|
||||
fasterMembershipChecks: false
|
||||
|
||||
# A case-insensitive list of ban reasons to automatically redact a user's
|
||||
|
@ -9,6 +9,9 @@ homeserverUrl: "http://localhost:8081"
|
||||
# Where the homeserver is located (client-server URL). NOT pantalaimon.
|
||||
rawHomeserverUrl: "http://localhost:8081"
|
||||
|
||||
# README: We use the Pantalaimon client WITHOUT Pantalaimon itself in tests (and the manual test)
|
||||
# as an easy way to login with passwords from the config without having
|
||||
# to resolve a chicken-vs-egg problem in regards to access tokens.
|
||||
# Pantalaimon options (https://github.com/matrix-org/pantalaimon)
|
||||
pantalaimon:
|
||||
# If true, accessToken above is ignored and the username/password below will be
|
||||
|
@ -167,7 +167,8 @@ web:
|
||||
# The address to listen for requests on. Defaults to all addresses.
|
||||
# Be careful with this setting, as opening to the wide web will increase
|
||||
# your security perimeter.
|
||||
address: localhost
|
||||
# We listen on all in harness because we might be getting requests through the docker gateway.
|
||||
address: "0.0.0.0"
|
||||
|
||||
# A web API designed to intercept Matrix API
|
||||
# POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
|
||||
@ -175,3 +176,7 @@ web:
|
||||
abuseReporting:
|
||||
# Whether to enable this feature.
|
||||
enabled: true
|
||||
# A web API for a description of all the combined rules from watched banlists.
|
||||
# GET /api/1/ruleserver/updates
|
||||
ruleServer:
|
||||
enabled: false
|
||||
|
@ -15,17 +15,27 @@ run:
|
||||
down:
|
||||
finally:
|
||||
- docker stop mjolnir-test-reverse-proxy || true
|
||||
|
||||
modules:
|
||||
- name: mjolnir
|
||||
build:
|
||||
- cp -r synapse_antispam $MX_TEST_MODULE_DIR
|
||||
config:
|
||||
module: mjolnir.Module
|
||||
config: {}
|
||||
|
||||
|
||||
homeserver:
|
||||
# Basic configuration.
|
||||
server_name: localhost:9999
|
||||
public_baseurl: http://localhost:9999
|
||||
registration_shared_secret: REGISTRATION_SHARED_SECRET
|
||||
# Make manual testing easier
|
||||
enable_registration: true
|
||||
|
||||
# Getting rid of throttling.
|
||||
rc_message:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
||||
# We remove rc_message so we can test rate limiting,
|
||||
# but we keep the others because of https://github.com/matrix-org/synapse/issues/11785
|
||||
# and we don't want to slow integration tests down.
|
||||
rc_registration:
|
||||
per_second: 10000
|
||||
burst_count: 10000
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mjolnir",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.1",
|
||||
"description": "A moderation tool for Matrix",
|
||||
"main": "lib/index.js",
|
||||
"repository": "git@github.com:matrix-org/mjolnir.git",
|
||||
@ -17,12 +17,12 @@
|
||||
"test:manual": "NODE_ENV=harness ts-node test/integration/manualLaunchScript.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/config": "0.0.41",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/html-to-text": "^8.0.1",
|
||||
"@types/jsdom": "^16.2.11",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^16.7.10",
|
||||
"axios": "^0.21.4",
|
||||
"crypto-js": "^4.1.1",
|
||||
"eslint": "^7.32",
|
||||
"expect": "^27.0.6",
|
||||
@ -34,7 +34,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"config": "^3.3.6",
|
||||
"escape-html": "^1.0.3",
|
||||
"express": "^4.17",
|
||||
"html-to-text": "^8.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||
export const ERROR_KIND_PERMISSION = "permission";
|
||||
export const ERROR_KIND_FATAL = "fatal";
|
||||
|
||||
const TRIGGER_INTERVALS = {
|
||||
const TRIGGER_INTERVALS: { [key: string]: number } = {
|
||||
[ERROR_KIND_PERMISSION]: 3 * 60 * 60 * 1000, // 3 hours
|
||||
[ERROR_KIND_FATAL]: 15 * 60 * 1000, // 15 minutes
|
||||
};
|
||||
|
@ -16,8 +16,7 @@ limitations under the License.
|
||||
|
||||
import { LogLevel, LogService, TextualMessageEventContent } from "matrix-bot-sdk";
|
||||
import config from "./config";
|
||||
import { replaceRoomIdsWithPills } from "./utils";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape, replaceRoomIdsWithPills } from "./utils";
|
||||
|
||||
const levelToFn = {
|
||||
[LogLevel.DEBUG.toString()]: LogService.debug,
|
||||
@ -37,7 +36,7 @@ export async function logMessage(level: LogLevel, module: string, message: strin
|
||||
|
||||
const client = config.RUNTIME.client;
|
||||
const managementRoomId = await client.resolveRoom(config.managementRoom);
|
||||
const roomIds = [managementRoomId, ...additionalRoomIds];
|
||||
const roomIds = new Set([managementRoomId, ...additionalRoomIds]);
|
||||
|
||||
let evContent: TextualMessageEventContent = {
|
||||
body: message,
|
||||
|
243
src/Mjolnir.ts
243
src/Mjolnir.ts
@ -36,12 +36,14 @@ import { logMessage } from "./LogProxy";
|
||||
import ErrorCache, { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "./ErrorCache";
|
||||
import { IProtection } from "./protections/IProtection";
|
||||
import { PROTECTIONS } from "./protections/protections";
|
||||
import { ProtectionSettingValidationError } from "./protections/ProtectionSettings";
|
||||
import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue";
|
||||
import { Healthz } from "./health/healthz";
|
||||
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape } from "./utils";
|
||||
import { ReportManager } from "./report/ReportManager";
|
||||
import { WebAPIs } from "./webapis/WebAPIs";
|
||||
import RuleServer from "./models/RuleServer";
|
||||
|
||||
export const STATE_NOT_STARTED = "not_started";
|
||||
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
|
||||
@ -57,7 +59,7 @@ export class Mjolnir {
|
||||
private displayName: string;
|
||||
private localpart: string;
|
||||
private currentState: string = STATE_NOT_STARTED;
|
||||
private protections: IProtection[] = [];
|
||||
public protections = new Map<string /* protection name */, IProtection>();
|
||||
/**
|
||||
* This is for users who are not listed on a watchlist,
|
||||
* but have been flagged by the automatic spam detection as suispicous
|
||||
@ -82,7 +84,7 @@ export class Mjolnir {
|
||||
* @param {boolean} options.autojoinOnlyIfManager Whether to only accept an invitation by a user present in the `managementRoom`.
|
||||
* @param {string} options.acceptInvitesFromGroup A group of users to accept invites from, ignores invites form users not in this group.
|
||||
*/
|
||||
private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixClient, options) {
|
||||
private static addJoinOnInviteListener(mjolnir: Mjolnir, client: MatrixClient, options: { [key: string]: any }) {
|
||||
client.on("room.invite", async (roomId: string, inviteEvent: any) => {
|
||||
const membershipEvent = new MembershipEvent(inviteEvent);
|
||||
|
||||
@ -144,7 +146,8 @@ export class Mjolnir {
|
||||
}
|
||||
await logMessage(LogLevel.INFO, "index", "Mjolnir is starting up. Use !mjolnir to query status.");
|
||||
|
||||
const mjolnir = new Mjolnir(client, managementRoomId, protectedRooms, banLists);
|
||||
const ruleServer = config.web.ruleServer ? new RuleServer() : null;
|
||||
const mjolnir = new Mjolnir(client, managementRoomId, protectedRooms, banLists, ruleServer);
|
||||
Mjolnir.addJoinOnInviteListener(mjolnir, client, config);
|
||||
return mjolnir;
|
||||
}
|
||||
@ -154,6 +157,8 @@ export class Mjolnir {
|
||||
public readonly managementRoomId: string,
|
||||
public readonly protectedRooms: { [roomId: string]: string },
|
||||
private banLists: BanList[],
|
||||
// Combines the rules from ban lists so they can be served to a homeserver module or another consumer.
|
||||
public readonly ruleServer: RuleServer|null,
|
||||
) {
|
||||
this.explicitlyProtectedRoomIds = Object.keys(this.protectedRooms);
|
||||
|
||||
@ -220,7 +225,9 @@ export class Mjolnir {
|
||||
|
||||
// Setup Web APIs
|
||||
console.log("Creating Web APIs");
|
||||
this.webapis = new WebAPIs(new ReportManager(this));
|
||||
const reportManager = new ReportManager(this);
|
||||
reportManager.on("report.new", this.handleReport);
|
||||
this.webapis = new WebAPIs(reportManager, this.ruleServer);
|
||||
}
|
||||
|
||||
public get lists(): BanList[] {
|
||||
@ -232,7 +239,7 @@ export class Mjolnir {
|
||||
}
|
||||
|
||||
public get enabledProtections(): IProtection[] {
|
||||
return this.protections;
|
||||
return [...this.protections.values()].filter(p => p.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -266,7 +273,7 @@ export class Mjolnir {
|
||||
await logMessage(LogLevel.DEBUG, "Mjolnir@startup", "Loading protected rooms...");
|
||||
await this.resyncJoinedRooms(false);
|
||||
try {
|
||||
const data: Object | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
const data: { rooms?: string[] } | null = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
if (data && data['rooms']) {
|
||||
for (const roomId of data['rooms']) {
|
||||
this.protectedRooms[roomId] = Permalinks.forRoom(roomId);
|
||||
@ -288,7 +295,7 @@ export class Mjolnir {
|
||||
if (config.syncOnStartup) {
|
||||
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
|
||||
await this.syncLists(config.verboseLogging);
|
||||
await this.enableProtections();
|
||||
await this.registerProtections();
|
||||
}
|
||||
|
||||
this.currentState = STATE_RUNNING;
|
||||
@ -323,15 +330,15 @@ export class Mjolnir {
|
||||
if (unprotectedIdx >= 0) this.knownUnprotectedRooms.splice(unprotectedIdx, 1);
|
||||
this.explicitlyProtectedRoomIds.push(roomId);
|
||||
|
||||
let additionalProtectedRooms;
|
||||
let additionalProtectedRooms: { rooms?: string[] } | null = null;
|
||||
try {
|
||||
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
|
||||
additionalProtectedRooms.rooms.push(roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
const rooms = (additionalProtectedRooms?.rooms ?? []);
|
||||
rooms.push(roomId);
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, { rooms: rooms });
|
||||
await this.syncLists(config.verboseLogging);
|
||||
}
|
||||
|
||||
@ -341,14 +348,13 @@ export class Mjolnir {
|
||||
const idx = this.explicitlyProtectedRoomIds.indexOf(roomId);
|
||||
if (idx >= 0) this.explicitlyProtectedRoomIds.splice(idx, 1);
|
||||
|
||||
let additionalProtectedRooms;
|
||||
let additionalProtectedRooms: { rooms?: string[] } | null = null;
|
||||
try {
|
||||
additionalProtectedRooms = await this.client.getAccountData(PROTECTED_ROOMS_EVENT_TYPE);
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
|
||||
additionalProtectedRooms.rooms = additionalProtectedRooms.rooms.filter(r => r !== roomId);
|
||||
additionalProtectedRooms = { rooms: additionalProtectedRooms?.rooms?.filter(r => r !== roomId) ?? [] };
|
||||
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
|
||||
}
|
||||
|
||||
@ -371,51 +377,162 @@ export class Mjolnir {
|
||||
}
|
||||
}
|
||||
|
||||
private async getEnabledProtections() {
|
||||
let enabled: string[] = [];
|
||||
try {
|
||||
const protections: Object | null = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
|
||||
if (protections && protections['enabled']) {
|
||||
for (const protection of protections['enabled']) {
|
||||
enabled.push(protection);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
private async enableProtections() {
|
||||
for (const protection of await this.getEnabledProtections()) {
|
||||
/*
|
||||
* Take all the builtin protections, register them to set their enabled (or not) state and
|
||||
* update their settings with any saved non-default values
|
||||
*/
|
||||
private async registerProtections() {
|
||||
for (const protection of PROTECTIONS) {
|
||||
try {
|
||||
this.enableProtection(protection, false);
|
||||
await this.registerProtection(protection);
|
||||
} catch (e) {
|
||||
LogService.warn("Mjolnir", extractRequestError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async enableProtection(protectionName: string, persist = true): Promise<any> {
|
||||
const definition = PROTECTIONS[protectionName];
|
||||
if (!definition) throw new Error("Failed to find protection by name: " + protectionName);
|
||||
|
||||
const protection = definition.factory();
|
||||
this.protections.push(protection);
|
||||
|
||||
if (persist) {
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
|
||||
/*
|
||||
* Make a list of the names of enabled protections and save them in a state event
|
||||
*/
|
||||
private async saveEnabledProtections() {
|
||||
const protections = this.enabledProtections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: protections });
|
||||
}
|
||||
/*
|
||||
* Enable a protection by name and persist its enable state in to a state event
|
||||
*
|
||||
* @param name The name of the protection whose settings we're enabling
|
||||
*/
|
||||
public async enableProtection(name: string) {
|
||||
const protection = this.protections.get(name);
|
||||
if (protection !== undefined) {
|
||||
protection.enabled = true;
|
||||
await this.saveEnabledProtections();
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Disable a protection by name and remove it from the persistent list of enabled protections
|
||||
*
|
||||
* @param name The name of the protection whose settings we're disabling
|
||||
*/
|
||||
public async disableProtection(name: string) {
|
||||
const protection = this.protections.get(name);
|
||||
if (protection !== undefined) {
|
||||
protection.enabled = false;
|
||||
await this.saveEnabledProtections();
|
||||
}
|
||||
}
|
||||
|
||||
public async disableProtection(protectionName: string): Promise<any> {
|
||||
const idx = this.protections.findIndex(p => p.name === protectionName);
|
||||
if (idx >= 0) this.protections.splice(idx, 1);
|
||||
/*
|
||||
* Read org.matrix.mjolnir.setting state event, find any saved settings for
|
||||
* the requested protectionName, then iterate and validate against their parser
|
||||
* counterparts in IProtection.settings and return those which validate
|
||||
*
|
||||
* @param protectionName The name of the protection whose settings we're reading
|
||||
* @returns Every saved setting for this protectionName that has a valid value
|
||||
*/
|
||||
public async getProtectionSettings(protectionName: string): Promise<{ [setting: string]: any }> {
|
||||
let savedSettings: { [setting: string]: any } = {}
|
||||
try {
|
||||
savedSettings = await this.client.getRoomStateEvent(
|
||||
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName
|
||||
);
|
||||
} catch {
|
||||
// setting does not exist, return empty object
|
||||
return {};
|
||||
}
|
||||
|
||||
const existing = this.protections.map(p => p.name);
|
||||
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
|
||||
const settingDefinitions = this.protections.get(protectionName)?.settings ?? {};
|
||||
const validatedSettings: { [setting: string]: any } = {}
|
||||
for (let [key, value] of Object.entries(savedSettings)) {
|
||||
if (
|
||||
// is this a setting name with a known parser?
|
||||
key in settingDefinitions
|
||||
// is the datatype of this setting's value what we expect?
|
||||
&& typeof(settingDefinitions[key].value) === typeof(value)
|
||||
// is this setting's value valid for the setting?
|
||||
&& settingDefinitions[key].validate(value)
|
||||
) {
|
||||
validatedSettings[key] = value;
|
||||
} else {
|
||||
await logMessage(
|
||||
LogLevel.WARN,
|
||||
"getProtectionSetting",
|
||||
`Tried to read ${protectionName}.${key} and got invalid value ${value}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return validatedSettings;
|
||||
}
|
||||
|
||||
/*
|
||||
* Takes an object of settings we want to change and what their values should be,
|
||||
* check that their values are valid, combine them with current saved settings,
|
||||
* then save the amalgamation to a state event
|
||||
*
|
||||
* @param protectionName Which protection these settings belong to
|
||||
* @param changedSettings The settings to change and their values
|
||||
*/
|
||||
public async setProtectionSettings(protectionName: string, changedSettings: { [setting: string]: any }): Promise<any> {
|
||||
const protection = this.protections.get(protectionName);
|
||||
if (protection === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validatedSettings: { [setting: string]: any } = await this.getProtectionSettings(protectionName);
|
||||
|
||||
for (let [key, value] of Object.entries(changedSettings)) {
|
||||
if (!(key in protection.settings)) {
|
||||
throw new ProtectionSettingValidationError(`Failed to find protection setting by name: ${key}`);
|
||||
}
|
||||
if (typeof(protection.settings[key].value) !== typeof(value)) {
|
||||
throw new ProtectionSettingValidationError(`Invalid type for protection setting: ${key} (${typeof(value)})`);
|
||||
}
|
||||
if (!protection.settings[key].validate(value)) {
|
||||
throw new ProtectionSettingValidationError(`Invalid value for protection setting: ${key} (${value})`);
|
||||
}
|
||||
validatedSettings[key] = value;
|
||||
}
|
||||
|
||||
await this.client.sendStateEvent(
|
||||
this.managementRoomId, 'org.matrix.mjolnir.setting', protectionName, validatedSettings
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Given a protection object; add it to our list of protections, set whether it is enabled
|
||||
* and update its settings with any saved non-default values.
|
||||
*
|
||||
* @param protection The protection object we want to register
|
||||
*/
|
||||
public async registerProtection(protection: IProtection): Promise<any> {
|
||||
this.protections.set(protection.name, protection)
|
||||
|
||||
let enabledProtections: { enabled: string[] } | null = null;
|
||||
try {
|
||||
enabledProtections = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
|
||||
} catch {
|
||||
// this setting either doesn't exist, or we failed to read it (bad network?)
|
||||
// TODO: retry on certain failures?
|
||||
}
|
||||
protection.enabled = enabledProtections?.enabled.includes(protection.name) ?? false;
|
||||
|
||||
const savedSettings = await this.getProtectionSettings(protection.name);
|
||||
for (let [key, value] of Object.entries(savedSettings)) {
|
||||
// this.getProtectionSettings() validates this data for us, so we don't need to
|
||||
protection.settings[key].setValue(value);
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Given a protection object; remove it from our list of protections.
|
||||
*
|
||||
* @param protection The protection object we want to unregister
|
||||
*/
|
||||
public unregisterProtection(protectionName: string) {
|
||||
if (!(protectionName in this.protections)) {
|
||||
throw new Error("Failed to find protection by name: " + protectionName);
|
||||
}
|
||||
this.protections.delete(protectionName);
|
||||
}
|
||||
|
||||
public async watchList(roomRef: string): Promise<BanList | null> {
|
||||
@ -431,6 +548,7 @@ export class Mjolnir {
|
||||
if (this.banLists.find(b => b.roomId === roomId)) return null;
|
||||
|
||||
const list = new BanList(roomId, roomRef, this.client);
|
||||
this.ruleServer?.watch(list);
|
||||
await list.updateList();
|
||||
this.banLists.push(list);
|
||||
|
||||
@ -449,12 +567,14 @@ export class Mjolnir {
|
||||
|
||||
const roomId = await this.client.resolveRoom(permalink.roomIdOrAlias);
|
||||
const list = this.banLists.find(b => b.roomId === roomId) || null;
|
||||
if (list) this.banLists.splice(this.banLists.indexOf(list), 1);
|
||||
if (list) {
|
||||
this.banLists.splice(this.banLists.indexOf(list), 1);
|
||||
this.ruleServer?.unwatch(list);
|
||||
}
|
||||
|
||||
await this.client.setAccountData(WATCHED_LISTS_EVENT_TYPE, {
|
||||
references: this.banLists.map(b => b.roomRef),
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
@ -469,8 +589,8 @@ export class Mjolnir {
|
||||
this.applyUnprotectedRooms();
|
||||
|
||||
try {
|
||||
const accountData: Object | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
|
||||
if (accountData && accountData['warned']) return; // already warned
|
||||
const accountData: { warned: boolean } | null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
|
||||
if (accountData && accountData.warned) return; // already warned
|
||||
} catch (e) {
|
||||
// Ignore - probably haven't warned about it yet
|
||||
}
|
||||
@ -489,14 +609,14 @@ export class Mjolnir {
|
||||
const banLists: BanList[] = [];
|
||||
const joinedRooms = await this.client.getJoinedRooms();
|
||||
|
||||
let watchedListsEvent = {};
|
||||
let watchedListsEvent: { references?: string[] } | null = null;
|
||||
try {
|
||||
watchedListsEvent = await this.client.getAccountData(WATCHED_LISTS_EVENT_TYPE);
|
||||
} catch (e) {
|
||||
// ignore - not important
|
||||
}
|
||||
|
||||
for (const roomRef of (watchedListsEvent['references'] || [])) {
|
||||
for (const roomRef of (watchedListsEvent?.references || [])) {
|
||||
const permalink = Permalinks.parseUrl(roomRef);
|
||||
if (!permalink.roomIdOrAlias) continue;
|
||||
|
||||
@ -508,6 +628,7 @@ export class Mjolnir {
|
||||
await this.warnAboutUnprotectedBanListRoom(roomId);
|
||||
|
||||
const list = new BanList(roomId, roomRef, this.client);
|
||||
this.ruleServer?.watch(list);
|
||||
await list.updateList();
|
||||
banLists.push(list);
|
||||
}
|
||||
@ -693,8 +814,8 @@ export class Mjolnir {
|
||||
if (Object.keys(this.protectedRooms).includes(roomId)) {
|
||||
if (event['sender'] === await this.client.getUserId()) return; // Ignore ourselves
|
||||
|
||||
// Iterate all the protections
|
||||
for (const protection of this.protections) {
|
||||
// Iterate all the enabled protections
|
||||
for (const protection of this.enabledProtections) {
|
||||
try {
|
||||
await protection.handleEvent(this, roomId, event);
|
||||
} catch (e) {
|
||||
@ -765,7 +886,7 @@ export class Mjolnir {
|
||||
} else if (ruleKind === RULE_ROOM) {
|
||||
ruleKind = 'room';
|
||||
}
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation)}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
html += `<li>${change.changeType} ${htmlEscape(ruleKind)} (<code>${htmlEscape(rule.recommendation ?? "")}</code>): <code>${htmlEscape(rule.entity)}</code> (${htmlEscape(rule.reason)})</li>`;
|
||||
text += `* ${change.changeType} ${ruleKind} (${rule.recommendation}): ${rule.entity} (${rule.reason})\n`;
|
||||
}
|
||||
|
||||
@ -858,4 +979,10 @@ export class Mjolnir {
|
||||
public async processRedactionQueue(roomId?: string): Promise<RoomUpdateError[]> {
|
||||
return await this.eventRedactionQueue.process(this.client, roomId);
|
||||
}
|
||||
|
||||
private async handleReport(roomId: string, reporterId: string, event: any, reason?: string) {
|
||||
for (const protection of this.enabledProtections) {
|
||||
await protection.handleReport(this, roomId, reporterId, event, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import { ServerAcl } from "../models/ServerAcl";
|
||||
import { RoomUpdateError } from "../models/RoomUpdateError";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import config from "../config";
|
||||
import { LogLevel } from "matrix-bot-sdk";
|
||||
import { LogLevel, UserID } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
|
||||
@ -31,8 +31,10 @@ import { ERROR_KIND_FATAL, ERROR_KIND_PERMISSION } from "../ErrorCache";
|
||||
* @param {Mjolnir} mjolnir The Mjolnir client to apply the ACLs with.
|
||||
*/
|
||||
export async function applyServerAcls(lists: BanList[], roomIds: string[], mjolnir: Mjolnir): Promise<RoomUpdateError[]> {
|
||||
const serverName: string = new UserID(await config.RUNTIME.client!.getUserId()).domain;
|
||||
|
||||
// Construct a server ACL first
|
||||
const acl = new ServerAcl().denyIpAddresses().allowServer("*");
|
||||
const acl = new ServerAcl(serverName).denyIpAddresses().allowServer("*");
|
||||
for (const list of lists) {
|
||||
for (const rule of list.serverRules) {
|
||||
acl.denyServer(rule.entity);
|
||||
@ -41,6 +43,10 @@ export async function applyServerAcls(lists: BanList[], roomIds: string[], mjoln
|
||||
|
||||
const finalAcl = acl.safeAclContent();
|
||||
|
||||
if (finalAcl.deny.length !== acl.literalAclContent().deny.length) {
|
||||
logMessage(LogLevel.WARN, "ApplyAcl", `Mjölnir has detected and removed an ACL that would exclude itself. Please check the ACL lists.`);
|
||||
}
|
||||
|
||||
if (config.verboseLogging) {
|
||||
// We specifically use sendNotice to avoid having to escape HTML
|
||||
await mjolnir.client.sendNotice(mjolnir.managementRoomId, `Constructed server ACL:\n${JSON.stringify(finalAcl, null, 2)}`);
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { RichReply } from "matrix-bot-sdk";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape } from "../utils";
|
||||
|
||||
// !mjolnir move <alias> <new room ID>
|
||||
export async function execMoveAliasCommand(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
|
@ -19,7 +19,7 @@ import { execStatusCommand } from "./StatusCommand";
|
||||
import { execBanCommand, execUnbanCommand } from "./UnbanBanCommand";
|
||||
import { execDumpRulesCommand } from "./DumpRulesCommand";
|
||||
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape } from "../utils";
|
||||
import { execSyncCommand } from "./SyncCommand";
|
||||
import { execPermissionCheckCommand } from "./PermissionCheckCommand";
|
||||
import { execCreateListCommand } from "./CreateBanListCommand";
|
||||
@ -28,7 +28,8 @@ import { execRedactCommand } from "./RedactCommand";
|
||||
import { execImportCommand } from "./ImportCommand";
|
||||
import { execSetDefaultListCommand } from "./SetDefaultBanListCommand";
|
||||
import { execDeactivateCommand } from "./DeactivateCommand";
|
||||
import { execDisableProtection, execEnableProtection, execListProtections } from "./ProtectionsCommands";
|
||||
import { execDisableProtection, execEnableProtection, execListProtections, execConfigGetProtection,
|
||||
execConfigSetProtection, execConfigAddProtection, execConfigRemoveProtection } from "./ProtectionsCommands";
|
||||
import { execListProtectedRooms } from "./ListProtectedRoomsCommand";
|
||||
import { execAddProtectedRoom, execRemoveProtectedRoom } from "./AddRemoveProtectedRoomsCommand";
|
||||
import { execAddRoomToDirectoryCommand, execRemoveRoomFromDirectoryCommand } from "./AddRemoveRoomFromDirectoryCommand";
|
||||
@ -39,7 +40,7 @@ import { execKickCommand } from "./KickCommand";
|
||||
|
||||
export const COMMAND_PREFIX = "!mjolnir";
|
||||
|
||||
export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir) {
|
||||
export async function handleCommand(roomId: string, event: { content: { body: string } }, mjolnir: Mjolnir) {
|
||||
const cmd = event['content']['body'];
|
||||
const parts = cmd.trim().split(' ').filter(p => p.trim().length > 0);
|
||||
|
||||
@ -76,6 +77,14 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
|
||||
return await execEnableProtection(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'disable' && parts.length > 1) {
|
||||
return await execDisableProtection(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'config' && parts[2] === 'set' && parts.length > 3) {
|
||||
return await execConfigSetProtection(roomId, event, mjolnir, parts.slice(3))
|
||||
} else if (parts[1] === 'config' && parts[2] === 'add' && parts.length > 3) {
|
||||
return await execConfigAddProtection(roomId, event, mjolnir, parts.slice(3))
|
||||
} else if (parts[1] === 'config' && parts[2] === 'remove' && parts.length > 3) {
|
||||
return await execConfigRemoveProtection(roomId, event, mjolnir, parts.slice(3))
|
||||
} else if (parts[1] === 'config' && parts[2] === 'get') {
|
||||
return await execConfigGetProtection(roomId, event, mjolnir, parts.slice(3))
|
||||
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'add') {
|
||||
return await execAddProtectedRoom(roomId, event, mjolnir, parts);
|
||||
} else if (parts[1] === 'rooms' && parts.length > 3 && parts[2] === 'remove') {
|
||||
@ -122,6 +131,10 @@ export async function handleCommand(roomId: string, event: any, mjolnir: Mjolnir
|
||||
"!mjolnir protections - List all available protections\n" +
|
||||
"!mjolnir enable <protection> - Enables a particular protection\n" +
|
||||
"!mjolnir disable <protection> - Disables a particular protection\n" +
|
||||
"!mjolnir config set <protection>.<setting> [value] - Change a projection setting\n" +
|
||||
"!mjolnir config add <protection>.<setting> [value] - Add a value to a list protection setting\n" +
|
||||
"!mjolnir config remove <protection>.<setting> [value] - Remove a value from a list protection setting\n" +
|
||||
"!mjolnir config get [protection] - List protection settings\n" +
|
||||
"!mjolnir rooms - Lists all the protected rooms\n" +
|
||||
"!mjolnir rooms add <room alias/ID> - Adds a protected room (may cause high server load)\n" +
|
||||
"!mjolnir rooms remove <room alias/ID> - Removes a protected room\n" +
|
||||
|
@ -23,7 +23,7 @@ export async function execCreateListCommand(roomId: string, event: any, mjolnir:
|
||||
const shortcode = parts[3];
|
||||
const aliasLocalpart = parts[4];
|
||||
|
||||
const powerLevels = {
|
||||
const powerLevels: { [key: string]: any } = {
|
||||
"ban": 50,
|
||||
"events": {
|
||||
"m.room.name": 100,
|
||||
@ -38,12 +38,11 @@ export async function execCreateListCommand(roomId: string, event: any, mjolnir:
|
||||
"redact": 50,
|
||||
"state_default": 50,
|
||||
"users": {
|
||||
// populated in a moment
|
||||
[await mjolnir.client.getUserId()]: 100,
|
||||
[event["sender"]]: 50
|
||||
},
|
||||
"users_default": 0,
|
||||
};
|
||||
powerLevels['users'][await mjolnir.client.getUserId()] = 100;
|
||||
powerLevels['users'][event['sender']] = 50;
|
||||
|
||||
const listRoomId = await mjolnir.client.createRoom({
|
||||
preset: "public_chat",
|
||||
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { RichReply } from "matrix-bot-sdk";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape } from "../utils";
|
||||
|
||||
// !mjolnir rules
|
||||
export async function execDumpRulesCommand(roomId: string, event: any, mjolnir: Mjolnir) {
|
||||
|
@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { htmlEscape } from "../utils";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { extractRequestError, LogService, RichReply } from "matrix-bot-sdk";
|
||||
import { PROTECTIONS } from "../protections/protections";
|
||||
import { isListSetting } from "../protections/ProtectionSettings";
|
||||
|
||||
// !mjolnir enable <protection>
|
||||
export async function execEnableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
@ -33,6 +34,166 @@ export async function execEnableProtection(roomId: string, event: any, mjolnir:
|
||||
}
|
||||
}
|
||||
|
||||
enum ConfigAction {
|
||||
Set,
|
||||
Add,
|
||||
Remove
|
||||
}
|
||||
|
||||
/*
|
||||
* Process a given ConfigAction against a given protection setting
|
||||
*
|
||||
* @param mjolnir Current Mjolnir instance
|
||||
* @param parts Arguments given to the command being processed
|
||||
* @param action Which ConfigAction to do to the provided protection setting
|
||||
* @returns Command success or failure message
|
||||
*/
|
||||
async function _execConfigChangeProtection(mjolnir: Mjolnir, parts: string[], action: ConfigAction): Promise<string> {
|
||||
const [protectionName, ...settingParts] = parts[0].split(".");
|
||||
const protection = mjolnir.protections.get(protectionName);
|
||||
if (protection === undefined) {
|
||||
return `Unknown protection ${protectionName}`;
|
||||
}
|
||||
|
||||
const defaultSettings = protection.settings
|
||||
const settingName = settingParts[0];
|
||||
const stringValue = parts[1];
|
||||
|
||||
if (!(settingName in defaultSettings)) {
|
||||
return `Unknown setting ${settingName}`;
|
||||
}
|
||||
|
||||
const parser = defaultSettings[settingName];
|
||||
// we don't need to validate `value`, because mjolnir.setProtectionSettings does
|
||||
// it for us (and raises an exception if there's a problem)
|
||||
let value = parser.fromString(stringValue);
|
||||
|
||||
if (action === ConfigAction.Add) {
|
||||
if (!isListSetting(parser)) {
|
||||
return `Setting ${settingName} isn't a list`;
|
||||
} else {
|
||||
value = parser.addValue(value);
|
||||
}
|
||||
} else if (action === ConfigAction.Remove) {
|
||||
if (!isListSetting(parser)) {
|
||||
return `Setting ${settingName} isn't a list`;
|
||||
} else {
|
||||
value = parser.removeValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mjolnir.setProtectionSettings(protectionName, { [settingName]: value });
|
||||
} catch (e) {
|
||||
return `Failed to set setting: ${e.message}`;
|
||||
}
|
||||
|
||||
const oldValue = protection.settings[settingName].value;
|
||||
protection.settings[settingName].setValue(value);
|
||||
|
||||
return `Changed ${protectionName}.${settingName} to ${value} (was ${oldValue})`;
|
||||
}
|
||||
|
||||
/*
|
||||
* Change a protection setting
|
||||
*
|
||||
* !mjolnir set <protection name>.<setting name> <value>
|
||||
*/
|
||||
export async function execConfigSetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Set);
|
||||
|
||||
const reply = RichReply.createFor(roomId, event, message, message);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
/*
|
||||
* Add a value to a protection list setting
|
||||
*
|
||||
* !mjolnir add <protection name>.<setting name> <value>
|
||||
*/
|
||||
export async function execConfigAddProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Add);
|
||||
|
||||
const reply = RichReply.createFor(roomId, event, message, message);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove a value from a protection list setting
|
||||
*
|
||||
* !mjolnir remove <protection name>.<setting name> <value>
|
||||
*/
|
||||
export async function execConfigRemoveProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
const message = await _execConfigChangeProtection(mjolnir, parts, ConfigAction.Remove);
|
||||
|
||||
const reply = RichReply.createFor(roomId, event, message, message);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get all protection settings or get all settings for a given protection
|
||||
*
|
||||
* !mjolnir get [protection name]
|
||||
*/
|
||||
export async function execConfigGetProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
let pickProtections = Object.keys(mjolnir.protections);
|
||||
|
||||
if (parts.length < 3) {
|
||||
// no specific protectionName provided, show all of them.
|
||||
|
||||
// sort output by protection name
|
||||
pickProtections.sort();
|
||||
} else {
|
||||
if (!pickProtections.includes(parts[0])) {
|
||||
const errMsg = `Unknown protection: ${parts[0]}`;
|
||||
const errReply = RichReply.createFor(roomId, event, errMsg, errMsg);
|
||||
errReply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, errReply);
|
||||
return;
|
||||
}
|
||||
pickProtections = [parts[0]];
|
||||
}
|
||||
|
||||
let text = "Protection settings\n";
|
||||
let html = "<b>Protection settings<b><br /><ul>";
|
||||
|
||||
let anySettings = false;
|
||||
|
||||
for (const protectionName of pickProtections) {
|
||||
const protectionSettings = mjolnir.protections.get(protectionName)?.settings ?? {};
|
||||
|
||||
if (Object.keys(protectionSettings).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const settingNames = Object.keys(protectionSettings);
|
||||
// this means, within each protection name, setting names are sorted
|
||||
settingNames.sort();
|
||||
for (const settingName of settingNames) {
|
||||
anySettings = true;
|
||||
|
||||
let value = protectionSettings[settingName].value
|
||||
text += `* ${protectionName}.${settingName}: ${value}`;
|
||||
// `protectionName` and `settingName` are user-provided but
|
||||
// validated against the names of existing protections and their
|
||||
// settings, so XSS is avoided for these already
|
||||
html += `<li><code>${protectionName}.${settingName}</code>: <code>${htmlEscape(value)}</code></li>`
|
||||
}
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
|
||||
if (!anySettings)
|
||||
html = text = "No settings found";
|
||||
|
||||
const reply = RichReply.createFor(roomId, event, text, html);
|
||||
reply["msgtype"] = "m.notice";
|
||||
await mjolnir.client.sendMessage(roomId, reply);
|
||||
}
|
||||
|
||||
// !mjolnir disable <protection>
|
||||
export async function execDisableProtection(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
await mjolnir.disableProtection(parts[2]);
|
||||
@ -41,16 +202,15 @@ export async function execDisableProtection(roomId: string, event: any, mjolnir:
|
||||
|
||||
// !mjolnir protections
|
||||
export async function execListProtections(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]) {
|
||||
const possibleProtections = Object.keys(PROTECTIONS);
|
||||
const enabledProtections = mjolnir.enabledProtections.map(p => p.name);
|
||||
|
||||
let html = "Available protections:<ul>";
|
||||
let text = "Available protections:\n";
|
||||
|
||||
for (const protection of possibleProtections) {
|
||||
const emoji = enabledProtections.includes(protection) ? '🟢 (enabled)' : '🔴 (disabled)';
|
||||
html += `<li>${emoji} <code>${protection}</code> - ${PROTECTIONS[protection].description}</li>`;
|
||||
text += `* ${emoji} ${protection} - ${PROTECTIONS[protection].description}\n`;
|
||||
for (const [protectionName, protection] of mjolnir.protections) {
|
||||
const emoji = enabledProtections.includes(protectionName) ? '🟢 (enabled)' : '🔴 (disabled)';
|
||||
html += `<li>${emoji} <code>${protectionName}</code> - ${protection.description}</li>`;
|
||||
text += `* ${emoji} ${protectionName} - ${protection.description}\n`;
|
||||
}
|
||||
|
||||
html += "</ul>";
|
||||
|
@ -31,9 +31,9 @@ interface Arguments {
|
||||
|
||||
// Exported for tests
|
||||
export async function parseArguments(roomId: string, event: any, mjolnir: Mjolnir, parts: string[]): Promise<Arguments|null> {
|
||||
let defaultShortcode = null;
|
||||
let defaultShortcode: string | null = null;
|
||||
try {
|
||||
const data: Object = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE);
|
||||
const data: { shortcode: string } = await mjolnir.client.getAccountData(DEFAULT_LIST_EVENT_TYPE);
|
||||
defaultShortcode = data['shortcode'];
|
||||
} catch (e) {
|
||||
LogService.warn("UnbanBanCommand", "Non-fatal error getting default ban list");
|
||||
|
@ -79,6 +79,9 @@ interface IConfig {
|
||||
abuseReporting: {
|
||||
enabled: boolean;
|
||||
}
|
||||
ruleServer?: {
|
||||
enabled: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +146,10 @@ const defaultConfig: IConfig = {
|
||||
address: "localhost",
|
||||
abuseReporting: {
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
ruleServer: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Needed to make the interface happy.
|
||||
|
@ -15,7 +15,8 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { extractRequestError, LogService, MatrixClient } from "matrix-bot-sdk";
|
||||
import { ListRule } from "./ListRule";
|
||||
import { EventEmitter } from "events";
|
||||
import { ListRule, RECOMMENDATION_BAN } from "./ListRule";
|
||||
|
||||
export const RULE_USER = "m.policy.rule.user";
|
||||
export const RULE_ROOM = "m.policy.rule.room";
|
||||
@ -70,11 +71,16 @@ export interface ListRuleChange {
|
||||
readonly previousState?: any,
|
||||
}
|
||||
|
||||
declare interface BanList {
|
||||
on(event: 'BanList.update', listener: (list: BanList, changes: ListRuleChange[]) => void): this
|
||||
emit(event: 'BanList.update', list: BanList, changes: ListRuleChange[]): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The BanList caches all of the rules that are active in a policy room so Mjolnir can refer to when applying bans etc.
|
||||
* This cannot be used to update events in the modeled room, it is a readonly model of the policy room.
|
||||
*/
|
||||
export default class BanList {
|
||||
class BanList extends EventEmitter {
|
||||
private shortcode: string|null = null;
|
||||
// A map of state events indexed first by state type and then state keys.
|
||||
private state: Map<string, Map<string, any>> = new Map();
|
||||
@ -85,7 +91,8 @@ export default class BanList {
|
||||
* @param roomRef A sharable/clickable matrix URL that refers to the room.
|
||||
* @param client A matrix client that is used to read the state of the room when `updateList` is called.
|
||||
*/
|
||||
constructor(public readonly roomId: string, public readonly roomRef, private client: MatrixClient) {
|
||||
constructor(public readonly roomId: string, public readonly roomRef: string, private client: MatrixClient) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -123,7 +130,7 @@ export default class BanList {
|
||||
|
||||
/**
|
||||
* Return all the active rules of a given kind.
|
||||
* @param kind e.g. RULE_SERVER (m.policy.rule.server)
|
||||
* @param kind e.g. RULE_SERVER (m.policy.rule.server). Rule types are always normalised when they are interned into the BanList.
|
||||
* @returns The active ListRules for the ban list of that kind.
|
||||
*/
|
||||
private rulesOfKind(kind: string): ListRule[] {
|
||||
@ -132,7 +139,10 @@ export default class BanList {
|
||||
if (stateKeyMap) {
|
||||
for (const event of stateKeyMap.values()) {
|
||||
const rule = event?.unsigned?.rule;
|
||||
if (rule && rule.kind === kind) {
|
||||
// README! If you are refactoring this and/or introducing a mechanism to return the list of rules,
|
||||
// please make sure that you *only* return rules with `m.ban` or create a different method
|
||||
// (we don't want to accidentally ban entities).
|
||||
if (rule && rule.kind === kind && rule.recommendation === RECOMMENDATION_BAN) {
|
||||
rules.push(rule);
|
||||
}
|
||||
}
|
||||
@ -268,6 +278,9 @@ export default class BanList {
|
||||
changes.push({rule, changeType, event, sender: event.sender, ... previousState ? {previousState} : {} });
|
||||
}
|
||||
}
|
||||
this.emit('BanList.update', this, changes);
|
||||
return changes;
|
||||
}
|
||||
}
|
||||
|
||||
export default BanList;
|
||||
|
@ -34,6 +34,7 @@ export class ListRule {
|
||||
|
||||
/**
|
||||
* The recommendation for this rule, or `null` if there is no recommendation or the recommendation is invalid.
|
||||
* Recommendations are normalised to their stable types.
|
||||
*/
|
||||
public get recommendation(): string|null {
|
||||
if (RECOMMENDATION_BAN_TYPES.includes(this.action)) return RECOMMENDATION_BAN;
|
||||
|
319
src/models/RuleServer.ts
Normal file
319
src/models/RuleServer.ts
Normal file
@ -0,0 +1,319 @@
|
||||
/*
|
||||
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 BanList, { ChangeType, ListRuleChange, RULE_ROOM, RULE_SERVER, RULE_USER } from "./BanList"
|
||||
import * as crypto from "crypto";
|
||||
import { LogService } from "matrix-bot-sdk";
|
||||
import { ListRule } from "./ListRule";
|
||||
|
||||
export const USER_MAY_INVITE = 'user_may_invite';
|
||||
export const CHECK_EVENT_FOR_SPAM = 'check_event_for_spam';
|
||||
|
||||
/**
|
||||
* Rules in the RuleServer format that have been produced from a single event.
|
||||
*/
|
||||
class EventRules {
|
||||
constructor (
|
||||
readonly eventId: string,
|
||||
readonly roomId: string,
|
||||
readonly ruleServerRules: RuleServerRule[],
|
||||
// The token associated with when the event rules were created.
|
||||
readonly token: number
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A description of a property that should be checked as part of a RuleServerRule.
|
||||
*/
|
||||
interface Checks {
|
||||
property: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Rule served by the rule server.
|
||||
*/
|
||||
interface RuleServerRule {
|
||||
// A unique identifier for this rule.
|
||||
readonly id: string
|
||||
// A description of a property that should be checked.
|
||||
readonly checks: Checks
|
||||
}
|
||||
|
||||
/**
|
||||
* The RuleServer is an experimental server that is used to propogate the rules of the watched policy rooms (BanLists) to
|
||||
* homeservers (or e.g. synapse modules).
|
||||
* This is done using an experimental format that is heavily based on the "Spam Checker Callbacks" made available to
|
||||
* synapse modules https://matrix-org.github.io/synapse/latest/modules/spam_checker_callbacks.html.
|
||||
*
|
||||
*/
|
||||
export default class RuleServer {
|
||||
// Each token is an index for a row of this two dimensional array.
|
||||
// Each row represents the rules that were added during the lifetime of that token.
|
||||
private ruleStartsByToken: EventRules[][] = [[]];
|
||||
|
||||
// Each row, indexed by a token, represents the rules that were stopped during the lifetime of that token.
|
||||
private ruleStopsByToken: string[][] = [[]];
|
||||
|
||||
// We use this to quickly lookup if we have stored a policy without scanning rulesByToken.
|
||||
// First key is the room id and the second is the event id.
|
||||
private rulesByEvent: Map<string, Map<string, EventRules>> = new Map();
|
||||
|
||||
// A unique identifier for this server instance that is given to each response so we can tell if the token
|
||||
// was issued by this server or not. This is important for when Mjolnir has been restarted
|
||||
// but the client consuming the rules hasn't been
|
||||
// and we need to tell the client we have rebuilt all of the rules (via `reset` in the response).
|
||||
private readonly serverId: string = crypto.randomUUID();
|
||||
|
||||
// Represents the current instant in which rules can started and/or stopped.
|
||||
// Should always be incremented before adding rules. See `nextToken`.
|
||||
private currentToken = 0;
|
||||
|
||||
private readonly banListUpdateListener = this.update.bind(this);
|
||||
|
||||
/**
|
||||
* The token is used to separate EventRules from each other based on when they were added.
|
||||
* The lower the token, the longer a rule has been tracked for (relative to other rules in this RuleServer).
|
||||
* The token is incremented before adding new rules to be served.
|
||||
*/
|
||||
private nextToken(): void {
|
||||
this.currentToken += 1;
|
||||
this.ruleStartsByToken.push([]);
|
||||
this.ruleStopsByToken.push([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a combination of the serverId and currentToken to give to the client.
|
||||
*/
|
||||
private get since(): string {
|
||||
return `${this.serverId}::${this.currentToken}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the `EventRules` object for a Matrix event.
|
||||
* @param roomId The room the event came from.
|
||||
* @param eventId The id of the event.
|
||||
* @returns The `EventRules` object describing which rules have been created based on the policy the event represents
|
||||
* or `undefined` if there are no `EventRules` associated with the event.
|
||||
*/
|
||||
private getEventRules(roomId: string, eventId: string): EventRules|undefined {
|
||||
return this.rulesByEvent.get(roomId)?.get(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the EventRule to be served by the rule server at the current token.
|
||||
* @param eventRules Add rules for an associated policy room event. (e.g. m.policy.rule.user).
|
||||
* @throws If there are already rules associated with the event specified in `eventRules.eventId`.
|
||||
*/
|
||||
private addEventRules(eventRules: EventRules): void {
|
||||
const {roomId, eventId, token} = eventRules;
|
||||
if (this.rulesByEvent.get(roomId)?.has(eventId)) {
|
||||
throw new TypeError(`There is already an entry in the RuleServer for rules created from the event ${eventId}.`);
|
||||
}
|
||||
const roomTable = this.rulesByEvent.get(roomId);
|
||||
if (roomTable) {
|
||||
roomTable.set(eventId, eventRules);
|
||||
} else {
|
||||
this.rulesByEvent.set(roomId, new Map().set(eventId, eventRules));
|
||||
}
|
||||
this.ruleStartsByToken[token].push(eventRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop serving the rules from this policy rule.
|
||||
* @param eventRules The EventRules to stop serving from the rule server.
|
||||
*/
|
||||
private stopEventRules(eventRules: EventRules): void {
|
||||
const {eventId, roomId, token} = eventRules;
|
||||
this.rulesByEvent.get(roomId)?.delete(eventId);
|
||||
// We expect that each row of `rulesByEvent` list of eventRules (represented by 1 row in `rulesByEvent`) to be relatively small (1-5)
|
||||
// as it can only contain eventRules added during the instant of time represented by one token.
|
||||
const index = this.ruleStartsByToken[token].indexOf(eventRules);
|
||||
if (index > -1) {
|
||||
this.ruleStartsByToken[token].splice(index, 1);
|
||||
}
|
||||
eventRules.ruleServerRules.map(rule => this.ruleStopsByToken[this.currentToken].push(rule.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the rule server to reflect a ListRule change.
|
||||
* @param change A ListRuleChange sourced from a BanList.
|
||||
*/
|
||||
private applyRuleChange(change: ListRuleChange): void {
|
||||
if (change.changeType === ChangeType.Added) {
|
||||
const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken);
|
||||
this.addEventRules(eventRules);
|
||||
} else if (change.changeType === ChangeType.Modified) {
|
||||
const entry: EventRules|undefined = this.getEventRules(change.event.roomId, change.previousState.event_id);
|
||||
if (entry === undefined) {
|
||||
LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`);
|
||||
return;
|
||||
}
|
||||
this.stopEventRules(entry);
|
||||
const eventRules = new EventRules(change.event.event_id, change.event.room_id, toRuleServerFormat(change.rule), this.currentToken);
|
||||
this.addEventRules(eventRules);
|
||||
} else if (change.changeType === ChangeType.Removed) {
|
||||
// 1) When the change is a redaction, the original version of the event will be available to us in `change.previousState`.
|
||||
// 2) When an event has been "soft redacted" (ie we have a new event with the same state type and state_key with no content),
|
||||
// the events in the `previousState` and `event` slots of `change` will be distinct events.
|
||||
// In either case (of redaction or "soft redaction") we can use `previousState` to get the right event id to stop.
|
||||
const entry: EventRules|undefined = this.getEventRules(change.event.room_id, change.previousState.event_id);
|
||||
if (entry === undefined) {
|
||||
LogService.error('RuleServer', `Could not find the rules for the previous modified state ${change.event['state_type']} ${change.event['state_key']} ${change.previousState?.event_id}`);
|
||||
return;
|
||||
}
|
||||
this.stopEventRules(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the ban list for changes and serve its policies as rules.
|
||||
* You will almost always want to call this before calling `updateList` on the BanList for the first time,
|
||||
* as we won't be able to serve rules that have already been interned in the BanList.
|
||||
* @param banList a BanList to watch for rule changes with.
|
||||
*/
|
||||
public watch(banList: BanList): void {
|
||||
banList.on('BanList.update', this.banListUpdateListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all of the rules that have been created from the policies in this banList.
|
||||
* @param banList The BanList to unwatch.
|
||||
*/
|
||||
public unwatch(banList: BanList): void {
|
||||
banList.removeListener('BanList.update', this.banListUpdateListener);
|
||||
const listRules = this.rulesByEvent.get(banList.roomId);
|
||||
this.nextToken();
|
||||
if (listRules) {
|
||||
for (const rule of listRules.values()) {
|
||||
this.stopEventRules(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the changes that have been made to a BanList.
|
||||
* This will ususally be called as a callback from `BanList.onChange`.
|
||||
* @param banList The BanList that the changes happened in.
|
||||
* @param changes An array of ListRuleChanges.
|
||||
*/
|
||||
private update(banList: BanList, changes: ListRuleChange[]) {
|
||||
if (changes.length > 0) {
|
||||
this.nextToken();
|
||||
changes.forEach(this.applyRuleChange, this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all of the new rules since the token.
|
||||
* @param sinceToken A token that has previously been issued by this server.
|
||||
* @returns An object with the rules that have been started and stopped since the token and a new token to poll for more rules with.
|
||||
*/
|
||||
public getUpdates(sinceToken: string | null): {start: RuleServerRule[], stop: string[], reset?: boolean, since: string} {
|
||||
const updatesSince = <T = EventRules|string>(token: number | null, policyStore: T[][]): T[] => {
|
||||
if (token === null) {
|
||||
// The client is requesting for the first time, we will give them everything.
|
||||
return policyStore.flat();
|
||||
} else if (token === this.currentToken) {
|
||||
// There will be no new rules to give this client, they're up to date.
|
||||
return [];
|
||||
} else {
|
||||
return policyStore.slice(token).flat();
|
||||
}
|
||||
}
|
||||
const [serverId, since] = sinceToken ? sinceToken.split('::') : [null, null];
|
||||
const parsedSince: number | null = since ? parseInt(since, 10) : null;
|
||||
if (serverId && serverId !== this.serverId) {
|
||||
// The server has restarted, but the client has not and still has rules we can no longer account for.
|
||||
// So we have to resend them everything.
|
||||
return {
|
||||
start: updatesSince(null, this.ruleStartsByToken).map((e: EventRules) => e.ruleServerRules).flat(),
|
||||
stop: updatesSince(null, this.ruleStopsByToken),
|
||||
since: this.since,
|
||||
reset: true
|
||||
}
|
||||
} else {
|
||||
// We will bring the client up to date on the rules.
|
||||
return {
|
||||
start: updatesSince(parsedSince, this.ruleStartsByToken).map((e: EventRules) => e.ruleServerRules).flat(),
|
||||
stop: updatesSince(parsedSince, this.ruleStopsByToken),
|
||||
since: this.since,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ListRule into the format that can be served by the rule server.
|
||||
* @param policyRule A ListRule to convert.
|
||||
* @returns An array of rules that can be served from the rule server.
|
||||
*/
|
||||
function toRuleServerFormat(policyRule: ListRule): RuleServerRule[] {
|
||||
function makeLiteral(literal: string) {
|
||||
return {literal}
|
||||
}
|
||||
|
||||
function makeGlob(glob: string) {
|
||||
return {glob}
|
||||
}
|
||||
|
||||
function makeServerGlob(server: string) {
|
||||
return {glob: `:${server}`}
|
||||
}
|
||||
|
||||
function makeRule(checks: Checks) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
checks: checks
|
||||
}
|
||||
}
|
||||
|
||||
if (policyRule.kind === RULE_USER) {
|
||||
// Block any messages or invites from being sent by a matching local user
|
||||
// Block any messages or invitations from being received that were sent by a matching remote user.
|
||||
return [{
|
||||
property: USER_MAY_INVITE,
|
||||
user_id: [makeGlob(policyRule.entity)]
|
||||
},
|
||||
{
|
||||
property: CHECK_EVENT_FOR_SPAM,
|
||||
sender: [makeGlob(policyRule.entity)]
|
||||
}].map(makeRule)
|
||||
} else if (policyRule.kind === RULE_ROOM) {
|
||||
// Block any messages being sent or received in the room, stop invitations being sent to the room and
|
||||
// stop anyone receiving invitations from the room.
|
||||
return [{
|
||||
property: USER_MAY_INVITE,
|
||||
'room_id': [makeLiteral(policyRule.entity)]
|
||||
},
|
||||
{
|
||||
property: CHECK_EVENT_FOR_SPAM,
|
||||
'room_id': [makeLiteral(policyRule.entity)]
|
||||
}].map(makeRule)
|
||||
} else if (policyRule.kind === RULE_SERVER) {
|
||||
// Block any invitations from the server or any new messages from the server.
|
||||
return [{
|
||||
property: USER_MAY_INVITE,
|
||||
user_id: [makeServerGlob(policyRule.entity)]
|
||||
},
|
||||
{
|
||||
property: CHECK_EVENT_FOR_SPAM,
|
||||
sender: [makeServerGlob(policyRule.entity)]
|
||||
}].map(makeRule)
|
||||
} else {
|
||||
LogService.info('RuleServer', `Ignoring unsupported policy rule type ${policyRule.kind}`);
|
||||
return []
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixGlob } from "matrix-bot-sdk";
|
||||
import { setToArray } from "../utils";
|
||||
|
||||
export interface ServerAclContent {
|
||||
@ -27,6 +28,28 @@ export class ServerAcl {
|
||||
private deniedServers: Set<string> = new Set<string>();
|
||||
private allowIps = false;
|
||||
|
||||
public constructor(public readonly homeserver: string) {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the ACL for any entries that might ban ourself.
|
||||
* @returns A list of deny entries that will not ban our own homeserver.
|
||||
*/
|
||||
public safeDeniedServers(): string[] {
|
||||
// The reason we do this check here rather than in the `denyServer` method
|
||||
// is because `literalAclContent` exists and also we want to be defensive about someone
|
||||
// mutating `this.deniedServers` via another method in the future.
|
||||
const entries: string[] = []
|
||||
for (const server of this.deniedServers) {
|
||||
const glob = new MatrixGlob(server);
|
||||
if (!glob.test(this.homeserver)) {
|
||||
entries.push(server);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public allowIpAddresses(): ServerAcl {
|
||||
this.allowIps = true;
|
||||
return this;
|
||||
@ -72,7 +95,7 @@ export class ServerAcl {
|
||||
}
|
||||
return {
|
||||
allow: allowed,
|
||||
deny: setToArray(this.deniedServers),
|
||||
deny: this.safeDeniedServers(),
|
||||
allow_ip_literals: this.allowIps,
|
||||
};
|
||||
}
|
||||
|
@ -14,26 +14,33 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { Protection } from "./IProtection";
|
||||
import { NumberProtectionSetting } from "./ProtectionSettings";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import config from "../config";
|
||||
|
||||
export const MAX_PER_MINUTE = 10; // if this is exceeded, we'll ban the user for spam and redact their messages
|
||||
// if this is exceeded, we'll ban the user for spam and redact their messages
|
||||
export const DEFAULT_MAX_PER_MINUTE = 10;
|
||||
const TIMESTAMP_THRESHOLD = 30000; // 30s out of phase
|
||||
|
||||
export class BasicFlooding implements IProtection {
|
||||
export class BasicFlooding extends Protection {
|
||||
|
||||
private lastEvents: { [roomId: string]: { [userId: string]: { originServerTs: number, eventId: string }[] } } = {};
|
||||
private recentlyBanned: string[] = [];
|
||||
|
||||
constructor() {
|
||||
}
|
||||
settings = {
|
||||
maxPerMinute: new NumberProtectionSetting(DEFAULT_MAX_PER_MINUTE)
|
||||
};
|
||||
|
||||
public get name(): string {
|
||||
return 'BasicFloodingProtection';
|
||||
}
|
||||
public get description(): string {
|
||||
return "If a user posts more than " + DEFAULT_MAX_PER_MINUTE + " messages in 60s they'll be " +
|
||||
"banned for spam. This does not publish the ban to any of your ban lists.";
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
if (!this.lastEvents[roomId]) this.lastEvents[roomId] = {};
|
||||
@ -56,7 +63,7 @@ export class BasicFlooding implements IProtection {
|
||||
messageCount++;
|
||||
}
|
||||
|
||||
if (messageCount >= MAX_PER_MINUTE) {
|
||||
if (messageCount >= this.settings.maxPerMinute.value) {
|
||||
await logMessage(LogLevel.WARN, "BasicFlooding", `Banning ${event['sender']} in ${roomId} for flooding (${messageCount} messages in the last minute)`, roomId);
|
||||
if (!config.noop) {
|
||||
await mjolnir.client.banUser(event['sender'], roomId, "spam");
|
||||
@ -82,8 +89,8 @@ export class BasicFlooding implements IProtection {
|
||||
}
|
||||
|
||||
// Trim the oldest messages off the user's history if it's getting large
|
||||
if (forUser.length > MAX_PER_MINUTE * 2) {
|
||||
forUser.splice(0, forUser.length - (MAX_PER_MINUTE * 2) - 1);
|
||||
if (forUser.length > this.settings.maxPerMinute.value * 2) {
|
||||
forUser.splice(0, forUser.length - (this.settings.maxPerMinute.value * 2) - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,24 +14,31 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { Protection } from "./IProtection";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import config from "../config";
|
||||
import { isTrueJoinEvent } from "../utils";
|
||||
|
||||
export class FirstMessageIsImage implements IProtection {
|
||||
export class FirstMessageIsImage extends Protection {
|
||||
|
||||
private justJoined: { [roomId: string]: string[] } = {};
|
||||
private recentlyBanned: string[] = [];
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'FirstMessageIsImageProtection';
|
||||
}
|
||||
public get description(): string {
|
||||
return "If the first thing a user does after joining is to post an image or video, " +
|
||||
"they'll be banned for spam. This does not publish the ban to any of your ban lists.";
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
if (!this.justJoined[roomId]) this.justJoined[roomId] = [];
|
||||
|
@ -15,6 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { AbstractProtectionSetting } from "./ProtectionSettings";
|
||||
|
||||
/**
|
||||
* Represents a protection mechanism of sorts. Protections are intended to be
|
||||
@ -24,5 +25,29 @@ import { Mjolnir } from "../Mjolnir";
|
||||
*/
|
||||
export interface IProtection {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
enabled: boolean;
|
||||
settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
||||
/*
|
||||
* Handle a single event from a protected room, to decide if we need to
|
||||
* respond to it
|
||||
*/
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any>;
|
||||
/*
|
||||
* Handle a single reported event from a protecte room, to decide if we
|
||||
* need to respond to it
|
||||
*/
|
||||
handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, reason: string, event: any): Promise<any>;
|
||||
}
|
||||
export abstract class Protection implements IProtection {
|
||||
abstract readonly name: string
|
||||
abstract readonly description: string;
|
||||
enabled = false;
|
||||
abstract settings: { [setting: string]: AbstractProtectionSetting<any, any> };
|
||||
handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
|
@ -14,20 +14,26 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { Protection } from "./IProtection";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import config from "../config";
|
||||
|
||||
export class MessageIsMedia implements IProtection {
|
||||
export class MessageIsMedia extends Protection {
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'MessageIsMediaProtection';
|
||||
}
|
||||
public get description(): string {
|
||||
return "If a user posts an image or video, that message will be redacted. No bans are issued.";
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
if (event['type'] === 'm.room.message') {
|
||||
|
@ -14,20 +14,26 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { Protection } from "./IProtection";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, Permalinks, UserID } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import config from "../config";
|
||||
|
||||
export class MessageIsVoice implements IProtection {
|
||||
export class MessageIsVoice extends Protection {
|
||||
|
||||
settings = {};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'MessageIsVoiceProtection';
|
||||
}
|
||||
public get description(): string {
|
||||
return "If a user posts a voice message, that message will be redacted. No bans are issued.";
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
if (event['type'] === 'm.room.message' && event['content']) {
|
||||
|
129
src/protections/ProtectionSettings.ts
Normal file
129
src/protections/ProtectionSettings.ts
Normal file
@ -0,0 +1,129 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
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> {
|
||||
// the current value of this setting
|
||||
value: TValue
|
||||
|
||||
/*
|
||||
* Deserialise a value for this setting type from a string
|
||||
*
|
||||
* @param data Serialised value
|
||||
* @returns Deserialised value or undefined if deserialisation failed
|
||||
*/
|
||||
fromString(data: string): TChange | undefined {
|
||||
throw new Error("not Implemented");
|
||||
}
|
||||
|
||||
/*
|
||||
* Check whether a given value is valid for this setting
|
||||
*
|
||||
* @param data Setting value
|
||||
* @returns Validity of provided value
|
||||
*/
|
||||
validate(data: TChange): boolean {
|
||||
throw new Error("not Implemented");
|
||||
}
|
||||
|
||||
/*
|
||||
* Store a value in this setting, only to be used after `validate()`
|
||||
* @param data Validated setting value
|
||||
*/
|
||||
setValue(data: TValue) {
|
||||
this.value = data;
|
||||
}
|
||||
}
|
||||
export class AbstractProtectionListSetting<TChange, TValue> extends AbstractProtectionSetting<TChange, TValue> {
|
||||
/*
|
||||
* Add `data` to the current setting value, and return that new object
|
||||
*
|
||||
* @param data Value to add to the current setting value
|
||||
* @returns The potential new value of this setting object
|
||||
*/
|
||||
addValue(data: TChange): TValue {
|
||||
throw new Error("not Implemented");
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove `data` from the current setting value, and return that new object
|
||||
*
|
||||
* @param data Value to remove from the current setting value
|
||||
* @returns The potential new value of this setting object
|
||||
*/
|
||||
removeValue(data: TChange): TValue {
|
||||
throw new Error("not Implemented");
|
||||
}
|
||||
}
|
||||
export function isListSetting(object: any): object is AbstractProtectionListSetting<any, any> {
|
||||
return object instanceof AbstractProtectionListSetting;
|
||||
}
|
||||
|
||||
|
||||
export class StringProtectionSetting extends AbstractProtectionSetting<string, string> {
|
||||
value = "";
|
||||
fromString = (data: string): string => data;
|
||||
validate = (data: string): boolean => true;
|
||||
}
|
||||
export class StringListProtectionSetting extends AbstractProtectionListSetting<string, string[]> {
|
||||
value: string[] = [];
|
||||
fromString = (data: string): string => data;
|
||||
validate = (data: string): boolean => true;
|
||||
addValue(data: string): string[] {
|
||||
return [...this.value, data];
|
||||
}
|
||||
removeValue(data: string): string[] {
|
||||
return this.value.filter(i => i !== data);
|
||||
}
|
||||
}
|
||||
|
||||
// A list of strings that match the glob pattern @*:*
|
||||
export class MXIDListProtectionSetting extends StringListProtectionSetting {
|
||||
// validate an individual piece of data for this setting - namely a single mxid
|
||||
validate = (data: string) => /^@\S+:\S+$/.test(data);
|
||||
}
|
||||
|
||||
export class NumberProtectionSetting extends AbstractProtectionSetting<number, number> {
|
||||
min: number|undefined;
|
||||
max: number|undefined;
|
||||
|
||||
constructor(
|
||||
defaultValue: number,
|
||||
min: number|undefined = undefined,
|
||||
max: number|undefined = undefined
|
||||
) {
|
||||
super();
|
||||
this.setValue(defaultValue);
|
||||
this.min = min;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
fromString(data: string) {
|
||||
let number = Number(data);
|
||||
return isNaN(number) ? undefined : number;
|
||||
}
|
||||
validate(data: number) {
|
||||
return (!isNaN(data)
|
||||
&& (this.min === undefined || this.min <= data)
|
||||
&& (this.max === undefined || data <= this.max))
|
||||
}
|
||||
|
||||
}
|
93
src/protections/TrustedReporters.ts
Normal file
93
src/protections/TrustedReporters.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
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 config from "../config";
|
||||
import { Protection } from "./IProtection";
|
||||
import { MXIDListProtectionSetting, NumberProtectionSetting } from "./ProtectionSettings";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
|
||||
const MAX_REPORTED_EVENT_BACKLOG = 20;
|
||||
|
||||
/*
|
||||
* Hold a list of users trusted to make reports, and enact consequences on
|
||||
* events that surpass configured report count thresholds
|
||||
*/
|
||||
export class TrustedReporters extends Protection {
|
||||
private recentReported = new Map<string /* eventId */, Set<string /* reporterId */>>();
|
||||
|
||||
settings = {
|
||||
mxids: new MXIDListProtectionSetting(),
|
||||
alertThreshold: new NumberProtectionSetting(3),
|
||||
// -1 means 'disabled'
|
||||
redactThreshold: new NumberProtectionSetting(-1),
|
||||
banThreshold: new NumberProtectionSetting(-1)
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
public get name(): string {
|
||||
return 'TrustedReporters';
|
||||
}
|
||||
public get description(): string {
|
||||
return "Count reports from trusted reporters and take a configured action";
|
||||
}
|
||||
|
||||
public async handleReport(mjolnir: Mjolnir, roomId: string, reporterId: string, event: any, reason?: string): Promise<any> {
|
||||
if (!this.settings.mxids.value.includes(reporterId)) {
|
||||
// not a trusted user, we're not interested
|
||||
return
|
||||
}
|
||||
|
||||
let reporters = this.recentReported.get(event.id);
|
||||
if (reporters === undefined) {
|
||||
// first report we've seen recently for this event
|
||||
reporters = new Set<string>();
|
||||
this.recentReported.set(event.id, reporters);
|
||||
if (this.recentReported.size > MAX_REPORTED_EVENT_BACKLOG) {
|
||||
// queue too big. push the oldest reported event off the queue
|
||||
const oldest = Array.from(this.recentReported.keys())[0];
|
||||
this.recentReported.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
reporters.add(reporterId);
|
||||
|
||||
let met: string[] = [];
|
||||
if (reporters.size === this.settings.alertThreshold.value) {
|
||||
met.push("alert");
|
||||
// do nothing. let the `sendMessage` call further down be the alert
|
||||
}
|
||||
if (reporters.size === this.settings.redactThreshold.value) {
|
||||
met.push("redact");
|
||||
await mjolnir.client.redactEvent(roomId, event.id, "abuse detected");
|
||||
}
|
||||
if (reporters.size === this.settings.banThreshold.value) {
|
||||
met.push("ban");
|
||||
await mjolnir.client.banUser(event.userId, roomId, "abuse detected");
|
||||
}
|
||||
|
||||
|
||||
if (met.length > 0) {
|
||||
await mjolnir.client.sendMessage(config.managementRoom, {
|
||||
msgtype: "m.notice",
|
||||
body: `message ${event.id} reported by ${[...reporters].join(', ')}. `
|
||||
+ `actions: ${met.join(', ')}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -14,19 +14,22 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { IProtection } from "./IProtection";
|
||||
import { Protection } from "./IProtection";
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
import { LogLevel, LogService } from "matrix-bot-sdk";
|
||||
import { logMessage } from "../LogProxy";
|
||||
import config from "../config";
|
||||
import { isTrueJoinEvent } from "../utils";
|
||||
|
||||
export class WordList implements IProtection {
|
||||
export class WordList extends Protection {
|
||||
|
||||
settings = {};
|
||||
|
||||
private justJoined: { [roomId: string]: { [username: string]: Date} } = {};
|
||||
private badWords: RegExp;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Create a mega-regex from all the tiny baby regexs
|
||||
this.badWords = new RegExp(
|
||||
"(" + config.protections.wordlist.words.join(")|(") + ")",
|
||||
@ -37,6 +40,10 @@ export class WordList implements IProtection {
|
||||
public get name(): string {
|
||||
return 'WordList';
|
||||
}
|
||||
public get description(): string {
|
||||
return "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.";
|
||||
}
|
||||
|
||||
public async handleEvent(mjolnir: Mjolnir, roomId: string, event: any): Promise<any> {
|
||||
|
||||
|
@ -16,40 +16,17 @@ limitations under the License.
|
||||
|
||||
import { FirstMessageIsImage } from "./FirstMessageIsImage";
|
||||
import { IProtection } from "./IProtection";
|
||||
import { BasicFlooding, MAX_PER_MINUTE } from "./BasicFlooding";
|
||||
import { BasicFlooding } from "./BasicFlooding";
|
||||
import { WordList } from "./WordList";
|
||||
import { MessageIsVoice } from "./MessageIsVoice";
|
||||
import { MessageIsMedia } from "./MessageIsMedia";
|
||||
import { TrustedReporters } from "./TrustedReporters";
|
||||
|
||||
export const PROTECTIONS: PossibleProtections = {
|
||||
[new FirstMessageIsImage().name]: {
|
||||
description: "If the first thing a user does after joining is to post an image or video, " +
|
||||
"they'll be banned for spam. This does not publish the ban to any of your ban lists.",
|
||||
factory: () => new FirstMessageIsImage(),
|
||||
},
|
||||
[new BasicFlooding().name]: {
|
||||
description: "If a user posts more than " + MAX_PER_MINUTE + " messages in 60s they'll be " +
|
||||
"banned for spam. This does not publish the ban to any of your ban lists.",
|
||||
factory: () => new BasicFlooding(),
|
||||
},
|
||||
[new WordList().name]: {
|
||||
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.",
|
||||
factory: () => new WordList(),
|
||||
},
|
||||
[new MessageIsVoice().name]: {
|
||||
description: "If a user posts a voice message, that message will be redacted. No bans are issued.",
|
||||
factory: () => new MessageIsVoice(),
|
||||
},
|
||||
[new MessageIsMedia().name]: {
|
||||
description: "If a user posts an image or video, that message will be redacted. No bans are issued.",
|
||||
factory: () => new MessageIsMedia(),
|
||||
}
|
||||
};
|
||||
|
||||
export interface PossibleProtections {
|
||||
[name: string]: {
|
||||
description: string;
|
||||
factory: () => IProtection;
|
||||
};
|
||||
}
|
||||
export const PROTECTIONS: IProtection[] = [
|
||||
new FirstMessageIsImage(),
|
||||
new BasicFlooding(),
|
||||
new WordList(),
|
||||
new MessageIsVoice(),
|
||||
new MessageIsMedia(),
|
||||
new TrustedReporters()
|
||||
];
|
||||
|
@ -17,8 +17,9 @@ limitations under the License.
|
||||
import { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction";
|
||||
import { LogService, UserID } from "matrix-bot-sdk";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { htmlEscape } from "../utils";
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import { Mjolnir } from "../Mjolnir";
|
||||
|
||||
@ -74,9 +75,10 @@ enum Kind {
|
||||
/**
|
||||
* A class designed to respond to abuse reports.
|
||||
*/
|
||||
export class ReportManager {
|
||||
export class ReportManager extends EventEmitter {
|
||||
private displayManager: DisplayManager;
|
||||
constructor(public mjolnir: Mjolnir) {
|
||||
super();
|
||||
// Configure bot interactions.
|
||||
mjolnir.client.on("room.event", async (roomId, event) => {
|
||||
try {
|
||||
@ -101,17 +103,17 @@ export class ReportManager {
|
||||
* The following MUST hold true:
|
||||
* - the reporter's id is `reporterId`;
|
||||
* - the reporter is a member of `roomId`;
|
||||
* - `eventId` did take place in room `roomId`;
|
||||
* - the reporter could witness event `eventId` in room `roomId`;
|
||||
* - `event` did take place in room `roomId`;
|
||||
* - the reporter could witness event `event` in room `roomId`;
|
||||
* - the event being reported is `event`;
|
||||
*
|
||||
* @param roomId The room in which the abuse took place.
|
||||
* @param eventId The ID of the event reported as abuse.
|
||||
* @param reporterId The user who reported the event.
|
||||
* @param event The event being reported.
|
||||
* @param reason A reason provided by the reporter.
|
||||
*/
|
||||
public async handleServerAbuseReport({ reporterId, event, reason }: { roomId: string, eventId: string, reporterId: string, event: any, reason?: string }) {
|
||||
public async handleServerAbuseReport({ roomId, reporterId, event, reason }: { roomId: string, reporterId: string, event: any, reason?: string }) {
|
||||
this.emit("report.new", { roomId: roomId, reporterId: reporterId, event: event, reason: reason });
|
||||
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: this.mjolnir.managementRoomId });
|
||||
}
|
||||
|
||||
@ -235,9 +237,9 @@ export class ReportManager {
|
||||
"m.relationship": {
|
||||
"rel_type": "m.reference",
|
||||
"event_id": relation.event_id,
|
||||
}
|
||||
},
|
||||
[ABUSE_ACTION_CONFIRMATION_KEY]: confirmationReport
|
||||
};
|
||||
confirmation[ABUSE_ACTION_CONFIRMATION_KEY] = confirmationReport;
|
||||
|
||||
let requestConfirmationEventId = await this.mjolnir.client.sendMessage(this.mjolnir.managementRoomId, confirmation);
|
||||
await this.mjolnir.client.sendEvent(this.mjolnir.managementRoomId, "m.reaction", {
|
||||
@ -693,7 +695,7 @@ class DisplayManager {
|
||||
// Ignore.
|
||||
}
|
||||
|
||||
let eventContent;
|
||||
let eventContent: { msg: string} | { html: string } | { text: string };
|
||||
try {
|
||||
if (event["type"] === "m.room.encrypted") {
|
||||
eventContent = { msg: "<encrypted content>" };
|
||||
@ -707,6 +709,8 @@ class DisplayManager {
|
||||
} else {
|
||||
eventContent = { text: this.limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) };
|
||||
}
|
||||
} else {
|
||||
eventContent = { msg: "Malformed event, cannot read content." };
|
||||
}
|
||||
} catch (ex) {
|
||||
eventContent = { msg: `<Cannot extract event. Please verify that Mjölnir has been invited to room ${roomAliasOrId} and made room moderator or administrator>.` };
|
||||
@ -716,12 +720,12 @@ class DisplayManager {
|
||||
|
||||
let reporterDisplayName: string, accusedDisplayName: string;
|
||||
try {
|
||||
reporterDisplayName = await this.owner.mjolnir.client.getUserProfile(reporterId)["displayname"] || reporterId;
|
||||
reporterDisplayName = (await this.owner.mjolnir.client.getUserProfile(reporterId))["displayname"] || reporterId;
|
||||
} catch (ex) {
|
||||
reporterDisplayName = "<Error: Cannot extract reporter display name>";
|
||||
}
|
||||
try {
|
||||
accusedDisplayName = await this.owner.mjolnir.client.getUserProfile(accusedId)["displayname"] || accusedId;
|
||||
accusedDisplayName = (await this.owner.mjolnir.client.getUserProfile(accusedId))["displayname"] || accusedId;
|
||||
} catch (ex) {
|
||||
accusedDisplayName = "<Error: Cannot extract accused display name>";
|
||||
}
|
||||
@ -832,8 +836,8 @@ class DisplayManager {
|
||||
}
|
||||
|
||||
// ...insert HTML content
|
||||
for (let [key, value] of [
|
||||
['event-content', eventContent],
|
||||
for (let {key, value} of [
|
||||
{ key: 'event-content', value: eventContent },
|
||||
]) {
|
||||
let node = document.getElementById(key);
|
||||
if (node) {
|
||||
@ -842,7 +846,7 @@ class DisplayManager {
|
||||
} else if ("text" in value) {
|
||||
node.textContent = value.text;
|
||||
} else if ("html" in value) {
|
||||
node.innerHTML = value.html;
|
||||
node.innerHTML = value.html
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -868,8 +872,8 @@ class DisplayManager {
|
||||
body: htmlToText(document.body.outerHTML, { wordwrap: false }),
|
||||
format: "org.matrix.custom.html",
|
||||
formatted_body: document.body.outerHTML,
|
||||
[ABUSE_REPORT_KEY]: report
|
||||
};
|
||||
notice[ABUSE_REPORT_KEY] = report;
|
||||
|
||||
let noticeEventId = await this.owner.mjolnir.client.sendMessage(this.owner.mjolnir.managementRoomId, notice);
|
||||
if (kind !== Kind.ERROR) {
|
||||
|
103
src/utils.ts
103
src/utils.ts
@ -29,9 +29,17 @@ import {
|
||||
} from "matrix-bot-sdk";
|
||||
import { logMessage } from "./LogProxy";
|
||||
import config from "./config";
|
||||
import * as htmlEscape from "escape-html";
|
||||
import { ClientRequest, IncomingMessage } from "http";
|
||||
|
||||
export function htmlEscape(input: string): string {
|
||||
return input.replace(/["&<>]/g, (char: string) => ({
|
||||
['"'.charCodeAt(0)]: """,
|
||||
["&".charCodeAt(0)]: "&",
|
||||
["<".charCodeAt(0)]: "<",
|
||||
[">".charCodeAt(0)]: ">"
|
||||
})[char.charCodeAt(0)]);
|
||||
}
|
||||
|
||||
export function setToArray<T>(set: Set<T>): T[] {
|
||||
const arr: T[] = [];
|
||||
for (const v of set) {
|
||||
@ -171,9 +179,19 @@ export async function getMessagesByUserIn(client: MatrixClient, sender: string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function replaceRoomIdsWithPills(client: MatrixClient, text: string, roomIds: string[] | string, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
if (!Array.isArray(roomIds)) roomIds = [roomIds];
|
||||
|
||||
/*
|
||||
* Take an arbitrary string and a set of room IDs, and return a
|
||||
* TextualMessageEventContent whose plaintext component replaces those room
|
||||
* IDs with their canonical aliases, and whose html component replaces those
|
||||
* room IDs with their matrix.to room pills.
|
||||
*
|
||||
* @param client The matrix client on which to query for room aliases
|
||||
* @param text An arbitrary string to rewrite with room aliases and pills
|
||||
* @param roomIds A set of room IDs to find and replace in `text`
|
||||
* @param msgtype The desired message type of the returned TextualMessageEventContent
|
||||
* @returns A TextualMessageEventContent with replaced room IDs
|
||||
*/
|
||||
export async function replaceRoomIdsWithPills(client: MatrixClient, text: string, roomIds: Set<string>, msgtype: MessageType = "m.text"): Promise<TextualMessageEventContent> {
|
||||
const content: TextualMessageEventContent = {
|
||||
body: text,
|
||||
formatted_body: htmlEscape(text),
|
||||
@ -225,11 +243,13 @@ function patchMatrixClientForConciseExceptions() {
|
||||
return;
|
||||
}
|
||||
let originalRequestFn = getRequestFn();
|
||||
setRequestFn((params, cb) => {
|
||||
setRequestFn((params: { [k: string]: any }, cb: any) => {
|
||||
// Store an error early, to maintain *some* semblance of stack.
|
||||
// We'll only throw the error if there is one.
|
||||
let error = new Error("STACK CAPTURE");
|
||||
originalRequestFn(params, function conciseExceptionRequestFn(err, response, resBody) {
|
||||
originalRequestFn(params, function conciseExceptionRequestFn(
|
||||
err: { [key: string]: any }, response: { [key: string]: any }, resBody: string
|
||||
) {
|
||||
if (!err && (response?.statusCode < 200 || response?.statusCode >= 300)) {
|
||||
// Normally, converting HTTP Errors into rejections is done by the caller
|
||||
// of `requestFn` within matrix-bot-sdk. However, this always ends up rejecting
|
||||
@ -313,6 +333,76 @@ function patchMatrixClientForConciseExceptions() {
|
||||
isMatrixClientPatchedForConciseExceptions = true;
|
||||
}
|
||||
|
||||
const MAX_REQUEST_ATTEMPTS = 15;
|
||||
const REQUEST_RETRY_BASE_DURATION_MS = 100;
|
||||
|
||||
const TRACE_CONCURRENT_REQUESTS = false;
|
||||
let numberOfConcurrentRequests = 0;
|
||||
let isMatrixClientPatchedForRetryWhenThrottled = false;
|
||||
/**
|
||||
* Patch instances of MatrixClient to make sure that it retries requests
|
||||
* in case of throttling.
|
||||
*
|
||||
* Note: As of this writing, we do not re-attempt requests that timeout,
|
||||
* only request that are throttled by the server. The rationale is that,
|
||||
* in case of DoS, we do not wish to make the situation even worse.
|
||||
*/
|
||||
function patchMatrixClientForRetry() {
|
||||
if (isMatrixClientPatchedForRetryWhenThrottled) {
|
||||
return;
|
||||
}
|
||||
let originalRequestFn = getRequestFn();
|
||||
setRequestFn(async (params: { [k: string]: any }, cb: any) => {
|
||||
let attempt = 1;
|
||||
numberOfConcurrentRequests += 1;
|
||||
if (TRACE_CONCURRENT_REQUESTS) {
|
||||
console.trace("Current number of concurrent requests", numberOfConcurrentRequests);
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
try {
|
||||
let result: any[] = await new Promise((resolve, reject) => {
|
||||
originalRequestFn(params, function requestFnWithRetry(
|
||||
err: { [key: string]: any }, response: { [key: string]: any }, resBody: string
|
||||
) {
|
||||
// Note: There is no data race on `attempt` as we `await` before continuing
|
||||
// to the next iteration of the loop.
|
||||
if (attempt < MAX_REQUEST_ATTEMPTS && err?.body?.errcode === 'M_LIMIT_EXCEEDED') {
|
||||
// We need to retry.
|
||||
reject(err);
|
||||
} else {
|
||||
// No need-to-retry error? Lucky us!
|
||||
// Note that this may very well be an error, just not
|
||||
// one we need to retry.
|
||||
resolve([err, response, resBody]);
|
||||
}
|
||||
});
|
||||
});
|
||||
// This is our final result.
|
||||
// Pass result, whether success or error.
|
||||
return cb(...result);
|
||||
} catch (err) {
|
||||
// Need to retry.
|
||||
let retryAfterMs = attempt * attempt * REQUEST_RETRY_BASE_DURATION_MS;
|
||||
if ("retry_after_ms" in err) {
|
||||
try {
|
||||
retryAfterMs = Number.parseInt(err.retry_after_ms, 10);
|
||||
} catch (ex) {
|
||||
// Use default value.
|
||||
}
|
||||
}
|
||||
LogService.debug("Mjolnir.client", `Waiting ${retryAfterMs}ms before retrying ${params.method} ${params.uri}`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryAfterMs));
|
||||
attempt += 1;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
numberOfConcurrentRequests -= 1;
|
||||
}
|
||||
});
|
||||
isMatrixClientPatchedForRetryWhenThrottled = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform any patching deemed necessary to MatrixClient.
|
||||
*/
|
||||
@ -324,4 +414,5 @@ export function patchMatrixClient() {
|
||||
// - `patchMatrixClientForRetry` expects that all errors are returned as
|
||||
// errors.
|
||||
patchMatrixClientForConciseExceptions();
|
||||
patchMatrixClientForRetry();
|
||||
}
|
||||
|
@ -17,11 +17,13 @@ limitations under the License.
|
||||
import { Server } from "http";
|
||||
|
||||
import * as express from "express";
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import { LogService, MatrixClient } from "matrix-bot-sdk";
|
||||
|
||||
import config from "../config";
|
||||
import RuleServer from "../models/RuleServer";
|
||||
import { ReportManager } from "../report/ReportManager";
|
||||
|
||||
|
||||
/**
|
||||
* A common prefix for all web-exposed APIs.
|
||||
*/
|
||||
@ -33,7 +35,7 @@ export class WebAPIs {
|
||||
private webController: express.Express = express();
|
||||
private httpServer?: Server;
|
||||
|
||||
constructor(private reportManager: ReportManager) {
|
||||
constructor(private reportManager: ReportManager, private readonly ruleServer: RuleServer|null) {
|
||||
// Setup JSON parsing.
|
||||
this.webController.use(express.json());
|
||||
}
|
||||
@ -56,6 +58,22 @@ export class WebAPIs {
|
||||
});
|
||||
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`);
|
||||
}
|
||||
|
||||
// Configure ruleServer API.
|
||||
// FIXME: Doesn't this need some kind of access control?
|
||||
// See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479.
|
||||
if (config.web.ruleServer?.enabled) {
|
||||
const updatesUrl = `${API_PREFIX}/ruleserver/updates`;
|
||||
LogService.info("WebAPIs", `Configuring ${updatesUrl}...`);
|
||||
if (!this.ruleServer) {
|
||||
throw new Error("The rule server to use has not been configured for the WebAPIs.");
|
||||
}
|
||||
const ruleServer: RuleServer = this.ruleServer;
|
||||
this.webController.get(updatesUrl, async (request, response) => {
|
||||
await this.handleRuleServerUpdate(ruleServer, { request, response, since: request.query.since as string});
|
||||
});
|
||||
LogService.info("WebAPIs", `Configuring ${updatesUrl}... DONE`);
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
@ -89,16 +107,19 @@ export class WebAPIs {
|
||||
{
|
||||
// -- Create a client on behalf of the reporter.
|
||||
// We'll use it to confirm the authenticity of the report.
|
||||
let accessToken;
|
||||
let accessToken: string | undefined = undefined;
|
||||
|
||||
// Authentication mechanism 1: Request header.
|
||||
let authorization = request.get('Authorization');
|
||||
|
||||
if (authorization) {
|
||||
[, accessToken] = AUTHORIZATION.exec(authorization)!;
|
||||
} else {
|
||||
} else if (typeof(request.query["access_token"]) === 'string') {
|
||||
// Authentication mechanism 2: Access token as query parameter.
|
||||
accessToken = request.query["access_token"];
|
||||
} else {
|
||||
response.status(401).send("Missing access token");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a client dedicated to this report.
|
||||
@ -154,7 +175,7 @@ export class WebAPIs {
|
||||
}
|
||||
|
||||
let reason = request.body["reason"];
|
||||
await this.reportManager.handleServerAbuseReport({ roomId, eventId, reporterId, event, reason });
|
||||
await this.reportManager.handleServerAbuseReport({ roomId, reporterId, event, reason });
|
||||
|
||||
// Match the spec behavior of `/report`: return 200 and an empty JSON.
|
||||
response.status(200).json({});
|
||||
@ -163,4 +184,16 @@ export class WebAPIs {
|
||||
response.status(503);
|
||||
}
|
||||
}
|
||||
|
||||
async handleRuleServerUpdate(ruleServer: RuleServer, { since, request, response }: { since: string, request: express.Request, response: express.Response }) {
|
||||
// FIXME Have to do this because express sends keep alive by default and during tests.
|
||||
// The server will never be able to close because express never closes the sockets, only stops accepting new connections.
|
||||
// See https://github.com/matrix-org/mjolnir/issues/139#issuecomment-1012221479.
|
||||
response.set("Connection", "close");
|
||||
try {
|
||||
response.json(ruleServer.getUpdates(since)).status(200);
|
||||
} catch (ex) {
|
||||
LogService.error("WebAPIs", `Error responding to a rule server updates request`, since, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1 +1,2 @@
|
||||
from .antispam import AntiSpam
|
||||
from .antispam import Module
|
||||
|
@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
# Copyright 2019-2022 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -14,13 +14,21 @@
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from typing import Dict, Union
|
||||
from .list_rule import ALL_RULE_TYPES, RECOMMENDATION_BAN
|
||||
from .ban_list import BanList
|
||||
from synapse.types import UserID
|
||||
from synapse.module_api import UserID
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class AntiSpam(object):
|
||||
"""
|
||||
Implements the old synapse spam-checker API, for compatibility with older configurations.
|
||||
|
||||
See https://github.com/matrix-org/synapse/blob/master/docs/spam_checker.md
|
||||
"""
|
||||
|
||||
def __init__(self, config, api):
|
||||
self.block_invites = config.get("block_invites", True)
|
||||
self.block_messages = config.get("block_messages", False)
|
||||
@ -77,7 +85,11 @@ class AntiSpam(object):
|
||||
state_key = event.get("state_key", None)
|
||||
|
||||
# Rebuild the rules if there's an event for our ban lists
|
||||
if state_key is not None and event_type in ALL_RULE_TYPES and room_id in self.list_room_ids:
|
||||
if (
|
||||
state_key is not None
|
||||
and event_type in ALL_RULE_TYPES
|
||||
and room_id in self.list_room_ids
|
||||
):
|
||||
logger.info("Received ban list event - updating list")
|
||||
self.get_list_for_room(room_id).build(with_event=event)
|
||||
return False # Ban list updates aren't spam
|
||||
@ -113,7 +125,9 @@ class AntiSpam(object):
|
||||
|
||||
# Check whether the user ID or display name matches any of the banned
|
||||
# patterns.
|
||||
return self.is_user_banned(user_profile["user_id"]) or self.is_user_banned(user_profile["display_name"])
|
||||
return self.is_user_banned(user_profile["user_id"]) or self.is_user_banned(
|
||||
user_profile["display_name"]
|
||||
)
|
||||
|
||||
def user_may_create_room(self, user_id):
|
||||
return True # allowed
|
||||
@ -127,3 +141,33 @@ class AntiSpam(object):
|
||||
@staticmethod
|
||||
def parse_config(config):
|
||||
return config # no parsing needed
|
||||
|
||||
|
||||
# New module API
|
||||
class Module:
|
||||
"""
|
||||
Our main entry point. Implements the Synapse Module API.
|
||||
"""
|
||||
|
||||
def __init__(self, config, api):
|
||||
self.antispam = AntiSpam(config, api)
|
||||
self.antispam.api.register_spam_checker_callbacks(
|
||||
check_event_for_spam=self.check_event_for_spam,
|
||||
user_may_invite=self.user_may_invite,
|
||||
check_username_for_spam=self.check_username_for_spam,
|
||||
)
|
||||
|
||||
# Callbacks for `register_spam_checker_callbacks`
|
||||
# Note that these are `async`, by opposition to the APIs in `AntiSpam`.
|
||||
async def check_event_for_spam(
|
||||
self, event: "synapse.events.EventBase"
|
||||
) -> Union[bool, str]:
|
||||
return self.antispam.check_event_for_spam(event)
|
||||
|
||||
async def user_may_invite(
|
||||
self, inviter_user_id: str, invitee_user_id: str, room_id: str
|
||||
) -> bool:
|
||||
return self.antispam.user_may_invite(inviter_user_id, invitee_user_id, room_id)
|
||||
|
||||
async def check_username_for_spam(self, user_profile: Dict[str, str]) -> bool:
|
||||
return self.antispam.check_username_for_spam(user_profile)
|
||||
|
@ -14,12 +14,19 @@
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
from .list_rule import ListRule, ALL_RULE_TYPES, USER_RULE_TYPES, SERVER_RULE_TYPES, ROOM_RULE_TYPES
|
||||
from .list_rule import (
|
||||
ListRule,
|
||||
ALL_RULE_TYPES,
|
||||
USER_RULE_TYPES,
|
||||
SERVER_RULE_TYPES,
|
||||
ROOM_RULE_TYPES,
|
||||
)
|
||||
from twisted.internet import defer
|
||||
from synapse.metrics.background_process_metrics import run_as_background_process
|
||||
from synapse.module_api import run_as_background_process
|
||||
|
||||
logger = logging.getLogger("synapse.contrib." + __name__)
|
||||
|
||||
|
||||
class BanList(object):
|
||||
def __init__(self, api, room_id):
|
||||
self.api = api
|
||||
@ -52,7 +59,11 @@ class BanList(object):
|
||||
w_state_key = with_event.get("state_key", "")
|
||||
w_event_id = with_event.event_id
|
||||
event_id = event.event_id
|
||||
if w_event_type == event_type and w_state_key == state_key and w_event_id != event_id:
|
||||
if (
|
||||
w_event_type == event_type
|
||||
and w_state_key == state_key
|
||||
and w_event_id != event_id
|
||||
):
|
||||
continue
|
||||
|
||||
entity = content.get("entity", None)
|
||||
@ -61,8 +72,13 @@ class BanList(object):
|
||||
if entity is None or recommendation is None or reason is None:
|
||||
continue # invalid event
|
||||
|
||||
logger.info("Adding rule %s/%s with action %s" % (event_type, entity, recommendation))
|
||||
rule = ListRule(entity=entity, action=recommendation, reason=reason, kind=event_type)
|
||||
logger.info(
|
||||
"Adding rule %s/%s with action %s"
|
||||
% (event_type, entity, recommendation)
|
||||
)
|
||||
rule = ListRule(
|
||||
entity=entity, action=recommendation, reason=reason, kind=event_type
|
||||
)
|
||||
if event_type in USER_RULE_TYPES:
|
||||
self.user_rules.append(rule)
|
||||
elif event_type in ROOM_RULE_TYPES:
|
||||
@ -73,4 +89,6 @@ class BanList(object):
|
||||
run_as_background_process("mjolnir_build_ban_list", run, with_event)
|
||||
|
||||
def get_relevant_state_events(self):
|
||||
return self.api.get_state_events_in_room(self.room_id, [(t, None) for t in ALL_RULE_TYPES])
|
||||
return self.api.get_state_events_in_room(
|
||||
self.room_id, [(t, None) for t in ALL_RULE_TYPES]
|
||||
)
|
||||
|
@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
# Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -13,17 +13,17 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from synapse.util import glob_to_regex
|
||||
from .matching import glob_to_regex
|
||||
|
||||
RECOMMENDATION_BAN = "m.ban"
|
||||
RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"]
|
||||
|
||||
RULE_USER = "m.room.rule.user"
|
||||
RULE_ROOM = "m.room.rule.room"
|
||||
RULE_SERVER = "m.room.rule.server"
|
||||
USER_RULE_TYPES = [RULE_USER, "org.matrix.mjolnir.rule.user"]
|
||||
ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"]
|
||||
SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"]
|
||||
RULE_USER = "m.policy.rule.user"
|
||||
RULE_ROOM = "m.policy.rule.room"
|
||||
RULE_SERVER = "m.policy.rule.server"
|
||||
USER_RULE_TYPES = [RULE_USER, "m.room.rule.user", "org.matrix.mjolnir.rule.user"]
|
||||
ROOM_RULE_TYPES = [RULE_ROOM, "m.room.rule.room", "org.matrix.mjolnir.rule.room"]
|
||||
SERVER_RULE_TYPES = [RULE_SERVER, "m.room.rule.server", "org.matrix.mjolnir.rule.server"]
|
||||
ALL_RULE_TYPES = [*USER_RULE_TYPES, *ROOM_RULE_TYPES, *SERVER_RULE_TYPES]
|
||||
|
||||
def recommendation_to_stable(recommendation):
|
||||
@ -31,6 +31,7 @@ def recommendation_to_stable(recommendation):
|
||||
return RECOMMENDATION_BAN
|
||||
return None
|
||||
|
||||
|
||||
def rule_type_to_stable(rule):
|
||||
if rule in USER_RULE_TYPES:
|
||||
return RULE_USER
|
||||
@ -40,6 +41,7 @@ def rule_type_to_stable(rule):
|
||||
return RULE_SERVER
|
||||
return None
|
||||
|
||||
|
||||
class ListRule(object):
|
||||
def __init__(self, entity, action, reason, kind):
|
||||
self.entity = entity
|
||||
|
73
synapse_antispam/mjolnir/matching.py
Normal file
73
synapse_antispam/mjolnir/matching.py
Normal file
@ -0,0 +1,73 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
#
|
||||
# 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.
|
||||
|
||||
# Tools in this file were copied from Synapse as these functions will not
|
||||
# remain publicly accessible in the module, see https://github.com/matrix-org/mjolnir/pull/174
|
||||
|
||||
import re
|
||||
from typing import Pattern
|
||||
|
||||
def re_word_boundary(r: str) -> str:
|
||||
"""
|
||||
Adds word boundary characters to the start and end of an
|
||||
expression to require that the match occur as a whole word,
|
||||
but do so respecting the fact that strings starting or ending
|
||||
with non-word characters will change word boundaries.
|
||||
"""
|
||||
# we can't use \b as it chokes on unicode. however \W seems to be okay
|
||||
# as shorthand for [^0-9A-Za-z_].
|
||||
return r"(^|\W)%s(\W|$)" % (r,)
|
||||
|
||||
_WILDCARD_RUN = re.compile(r"([\?\*]+)")
|
||||
def glob_to_regex(glob: str, word_boundary: bool = False) -> Pattern:
|
||||
"""Converts a glob to a compiled regex object.
|
||||
|
||||
Args:
|
||||
glob: pattern to match
|
||||
word_boundary: If True, the pattern will be allowed to match at word boundaries
|
||||
anywhere in the string. Otherwise, the pattern is anchored at the start and
|
||||
end of the string.
|
||||
|
||||
Returns:
|
||||
compiled regex pattern
|
||||
"""
|
||||
|
||||
# Patterns with wildcards must be simplified to avoid performance cliffs
|
||||
# - The glob `?**?**?` is equivalent to the glob `???*`
|
||||
# - The glob `???*` is equivalent to the regex `.{3,}`
|
||||
chunks = []
|
||||
for chunk in _WILDCARD_RUN.split(glob):
|
||||
# No wildcards? re.escape()
|
||||
if not _WILDCARD_RUN.match(chunk):
|
||||
chunks.append(re.escape(chunk))
|
||||
continue
|
||||
|
||||
# Wildcards? Simplify.
|
||||
qmarks = chunk.count("?")
|
||||
if "*" in chunk:
|
||||
chunks.append(".{%d,}" % qmarks)
|
||||
else:
|
||||
chunks.append(".{%d}" % qmarks)
|
||||
|
||||
res = "".join(chunks)
|
||||
|
||||
if word_boundary:
|
||||
res = re_word_boundary(res)
|
||||
else:
|
||||
# \A anchors at start of string, \Z at end of string
|
||||
res = r"\A" + res + r"\Z"
|
||||
|
||||
return re.compile(res, re.IGNORECASE)
|
||||
|
@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name="mjolnir",
|
||||
version="0.0.1",
|
||||
version="0.1.0",
|
||||
packages=find_packages(),
|
||||
description="Mjolnir Antispam",
|
||||
include_package_data=True,
|
||||
|
@ -32,8 +32,8 @@ describe("Test: Reporting abuse", async () => {
|
||||
});
|
||||
|
||||
// Create a few users and a room.
|
||||
let goodUser = await newTestUser(false, "reporting-abuse-good-user");
|
||||
let badUser = await newTestUser(false, "reporting-abuse-bad-user");
|
||||
let goodUser = await newTestUser({ name: { contains: "reporting-abuse-good-user" }});
|
||||
let badUser = await newTestUser({ name: { contains: "reporting-abuse-bad-user" }});
|
||||
let goodUserId = await goodUser.getUserId();
|
||||
let badUserId = await badUser.getUserId();
|
||||
|
||||
@ -227,13 +227,13 @@ describe("Test: Reporting abuse", async () => {
|
||||
});
|
||||
|
||||
// Create a moderator.
|
||||
let moderatorUser = await newTestUser(false, "reacting-abuse-moderator-user");
|
||||
let moderatorUser = await newTestUser({ name: { contains: "reporting-abuse-moderator-user" }});
|
||||
matrixClient().inviteUser(await moderatorUser.getUserId(), this.mjolnir.managementRoomId);
|
||||
await moderatorUser.joinRoom(this.mjolnir.managementRoomId);
|
||||
|
||||
// Create a few users and a room.
|
||||
let goodUser = await newTestUser(false, "reacting-abuse-good-user");
|
||||
let badUser = await newTestUser(false, "reacting-abuse-bad-user");
|
||||
let goodUser = await newTestUser({ name: { contains: "reacting-abuse-good-user" }});
|
||||
let badUser = await newTestUser({ name: { contains: "reacting-abuse-bad-user" }});
|
||||
let goodUserId = await goodUser.getUserId();
|
||||
let badUserId = await badUser.getUserId();
|
||||
|
||||
|
@ -2,8 +2,9 @@ import { strict as assert } from "assert";
|
||||
|
||||
import config from "../../src/config";
|
||||
import { newTestUser } from "./clientHelper";
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import BanList, { ChangeType, ListRuleChange, RULE_USER } from "../../src/models/BanList";
|
||||
import { MatrixClient, UserID } from "matrix-bot-sdk";
|
||||
import BanList, { ALL_RULE_TYPES, ChangeType, ListRuleChange, RULE_SERVER, RULE_USER } from "../../src/models/BanList";
|
||||
import { ServerAcl, ServerAclContent } from "../../src/models/ServerAcl";
|
||||
|
||||
/**
|
||||
* Create a policy rule in a policy room.
|
||||
@ -12,13 +13,14 @@ import BanList, { ChangeType, ListRuleChange, RULE_USER } from "../../src/model
|
||||
* @param policyType The type of policy to add e.g. m.policy.rule.user. (Use RULE_USER though).
|
||||
* @param entity The entity to ban e.g. @foo:example.org
|
||||
* @param reason A reason for the rule e.g. 'Wouldn't stop posting spam links'
|
||||
* @param template The template to use for the policy rule event.
|
||||
* @returns The event id of the newly created policy rule.
|
||||
*/
|
||||
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string) {
|
||||
async function createPolicyRule(client: MatrixClient, policyRoomId: string, policyType: string, entity: string, reason: string, template = {recommendation: 'm.ban'}) {
|
||||
return await client.sendStateEvent(policyRoomId, policyType, `rule:${entity}`, {
|
||||
entity,
|
||||
reason,
|
||||
recommendation: 'm.ban'
|
||||
...template,
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,7 +28,7 @@ describe("Test: Updating the BanList", function () {
|
||||
it("Calculates what has changed correctly.", async function () {
|
||||
this.timeout(10000);
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const moderator = await newTestUser(false, "moderator");
|
||||
const moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
|
||||
@ -117,7 +119,7 @@ describe("Test: Updating the BanList", function () {
|
||||
it("Will remove rules with old types when they are 'soft redacted' with a different but more recent event type.", async function () {
|
||||
this.timeout(3000);
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const moderator = await newTestUser(false, "moderator");
|
||||
const moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
|
||||
@ -139,7 +141,7 @@ describe("Test: Updating the BanList", function () {
|
||||
it("A rule of the most recent type won't be deleted when an old rule is deleted for the same entity.", async function () {
|
||||
this.timeout(3000);
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const moderator = await newTestUser(false, "moderator");
|
||||
const moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
const banListId = await mjolnir.createRoom({ invite: [await moderator.getUserId()]});
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
mjolnir.setUserPowerLevel(await moderator.getUserId(), banListId, 100);
|
||||
@ -175,4 +177,51 @@ describe("Test: Updating the BanList", function () {
|
||||
assert.equal(changes[0].previousState['event_id'], updatedEventId, 'There should be a previous state event for a modified rule');
|
||||
assert.equal(banList.userRules.filter(rule => rule.entity === entity).length, 0, 'The rule should no longer be stored.');
|
||||
})
|
||||
it('Test: BanList Supports all entity types.', async function () {
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const banListId = await mjolnir.createRoom();
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
for (let i = 0; i < ALL_RULE_TYPES.length; i++) {
|
||||
await createPolicyRule(mjolnir, banListId, ALL_RULE_TYPES[i], `*${i}*`, '');
|
||||
}
|
||||
let changes: ListRuleChange[] = await banList.updateList();
|
||||
assert.equal(changes.length, ALL_RULE_TYPES.length);
|
||||
assert.equal(banList.allRules.length, ALL_RULE_TYPES.length);
|
||||
})
|
||||
});
|
||||
|
||||
describe('Test: We do not respond to recommendations other than m.ban in the banlist', function () {
|
||||
it('Will not respond to a rule that has a different recommendation to m.ban (or the unstable equivalent).', async function () {
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const banListId = await mjolnir.createRoom();
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'exmaple.org', '', {recommendation: 'something that is not m.ban'});
|
||||
let changes: ListRuleChange[] = await banList.updateList();
|
||||
assert.equal(changes.length, 1, 'There should only be one change');
|
||||
assert.equal(changes[0].changeType, ChangeType.Added);
|
||||
assert.equal(changes[0].sender, await mjolnir.getUserId());
|
||||
// We really don't want things that aren't m.ban to end up being accessible in these APIs.
|
||||
assert.equal(banList.serverRules.length, 0);
|
||||
assert.equal(banList.allRules.length, 0);
|
||||
})
|
||||
})
|
||||
|
||||
describe('Test: We will not be able to ban ourselves via ACL.', function () {
|
||||
it('We do not ban ourselves when we put ourselves into the policy list.', async function () {
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
const serverName = new UserID(await mjolnir.getUserId()).domain;
|
||||
const banListId = await mjolnir.createRoom();
|
||||
const banList = new BanList(banListId, banListId, mjolnir);
|
||||
await createPolicyRule(mjolnir, banListId, RULE_SERVER, serverName, '');
|
||||
await createPolicyRule(mjolnir, banListId, RULE_SERVER, 'evil.com', '');
|
||||
await createPolicyRule(mjolnir, banListId, RULE_SERVER, '*', '');
|
||||
// We should still intern the matching rules rule.
|
||||
let changes: ListRuleChange[] = await banList.updateList();
|
||||
assert.equal(banList.serverRules.length, 3);
|
||||
// But when we construct an ACL, we should be safe.
|
||||
const acl = new ServerAcl(serverName)
|
||||
changes.forEach(change => acl.denyServer(change.rule.entity));
|
||||
assert.equal(acl.safeAclContent().deny.length, 1);
|
||||
assert.equal(acl.literalAclContent().deny.length, 3);
|
||||
})
|
||||
})
|
||||
|
@ -1,7 +1,6 @@
|
||||
import axios from "axios";
|
||||
import { HmacSHA1 } from "crypto-js";
|
||||
import { promises as fs } from "fs";
|
||||
import { LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient, RustSdkCryptoStorageProvider } from "matrix-bot-sdk";
|
||||
import { getRequestFn, LogService, MatrixClient, MemoryStorageProvider, PantalaimonClient, RustSdkCryptoStorageProvider } from "matrix-bot-sdk";
|
||||
import config from "../../src/config";
|
||||
|
||||
const REGISTRATION_ATTEMPTS = 10;
|
||||
@ -19,31 +18,64 @@ let CryptoStorePaths: string[] = [];
|
||||
* @param admin True to make the user an admin, false otherwise.
|
||||
* @returns The response from synapse.
|
||||
*/
|
||||
export async function registerUser(username: string, displayname: string, password: string, admin: boolean) {
|
||||
export async function registerUser(username: string, displayname: string, password: string, admin: boolean): Promise<{access_token: string}> {
|
||||
let registerUrl = `${config.homeserverUrl}/_synapse/admin/v1/register`
|
||||
let { data } = await axios.get(registerUrl);
|
||||
let nonce = data.nonce!;
|
||||
const data: {nonce: string} = await new Promise((resolve, reject) => {
|
||||
getRequestFn()({uri: registerUrl, method: "GET", timeout: 60000}, (error, response, resBody) => {
|
||||
error ? reject(error) : resolve(JSON.parse(resBody))
|
||||
});
|
||||
});
|
||||
const nonce = data.nonce!;
|
||||
let mac = HmacSHA1(`${nonce}\0${username}\0${password}\0${admin ? 'admin' : 'notadmin'}`, 'REGISTRATION_SHARED_SECRET');
|
||||
for (let i = 1; i <= REGISTRATION_ATTEMPTS; ++i) {
|
||||
try {
|
||||
return (await axios.post(registerUrl, {
|
||||
nonce,
|
||||
username,
|
||||
displayname,
|
||||
password,
|
||||
admin,
|
||||
mac: mac.toString()
|
||||
})).data;
|
||||
const params = {
|
||||
uri: registerUrl,
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({
|
||||
nonce,
|
||||
username,
|
||||
displayname,
|
||||
password,
|
||||
admin,
|
||||
mac: mac.toString()
|
||||
}),
|
||||
timeout: 60000
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
getRequestFn()(params, (error, result) => error ? reject(error) : resolve(result));
|
||||
});
|
||||
} catch (ex) {
|
||||
// In case of timeout or throttling, backoff and retry.
|
||||
if (ex?.code === 'ESOCKETTIMEDOUT' || ex?.code === 'ETIMEDOUT'
|
||||
|| ex?.response?.data?.errcode === 'M_LIMIT_EXCEEDED') {
|
||||
|| ex?.body?.errcode === 'M_LIMIT_EXCEEDED') {
|
||||
await new Promise(resolve => setTimeout(resolve, REGISTRATION_RETRY_BASE_DELAY_MS * i * i));
|
||||
continue;
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
throw new Error(`Retried registration ${REGISTRATION_ATTEMPTS} times, is Mjolnir or Synapse misconfigured?`);
|
||||
}
|
||||
|
||||
export type RegistrationOptions = {
|
||||
/**
|
||||
* If specified and true, make the user an admin.
|
||||
*/
|
||||
isAdmin?: boolean,
|
||||
/**
|
||||
* If `exact`, use the account with this exact name, attempting to reuse
|
||||
* an existing account if possible.
|
||||
*
|
||||
* If `contains` create a new account with a name that contains this
|
||||
* specific string.
|
||||
*/
|
||||
name: { exact: string } | { contains: string },
|
||||
/**
|
||||
* If specified and true, throttle this user.
|
||||
*/
|
||||
isThrottled?: boolean
|
||||
}
|
||||
|
||||
export async function getTempCryptoStore() {
|
||||
@ -53,54 +85,103 @@ export async function getTempCryptoStore() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new test user with a unique username.
|
||||
* Register a new test user.
|
||||
*
|
||||
* @param isAdmin Whether to make the new user an admin.
|
||||
* @param label If specified, a string to place somewhere within the username.
|
||||
* @returns A string that is the username and password of a new user.
|
||||
* @returns A string that is both the username and password of a new user.
|
||||
*/
|
||||
export async function registerNewTestUser(isAdmin: boolean, label: string = "") {
|
||||
let isUserValid = false;
|
||||
let username;
|
||||
if (label != "") {
|
||||
label += "-";
|
||||
}
|
||||
async function registerNewTestUser(options: RegistrationOptions) {
|
||||
do {
|
||||
username = `mjolnir-test-user-${label}${Math.floor(Math.random() * 100000)}`;
|
||||
let username;
|
||||
if ("exact" in options.name) {
|
||||
username = options.name.exact;
|
||||
} else {
|
||||
username = `mjolnir-test-user-${options.name.contains}${Math.floor(Math.random() * 100000)}`
|
||||
}
|
||||
try {
|
||||
const { access_token } = await registerUser(username, username, username, isAdmin);
|
||||
isUserValid = true;
|
||||
return {username, access_token};
|
||||
const { access_token } = await registerUser(username, username, username, Boolean(options.isAdmin));
|
||||
return { access_token, username };
|
||||
} catch (e) {
|
||||
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
|
||||
LogService.debug("test/clientHelper", `${username} already registered, trying another`);
|
||||
// continue and try again
|
||||
if (e?.body?.errcode === 'M_USER_IN_USE') {
|
||||
if ("exact" in options.name) {
|
||||
LogService.debug("test/clientHelper", `${username} already registered, reusing`);
|
||||
return { username };
|
||||
} else {
|
||||
LogService.debug("test/clientHelper", `${username} already registered, trying another`);
|
||||
}
|
||||
} else {
|
||||
console.error(`failed to register user ${e}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} while (!isUserValid);
|
||||
} while (true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a unique test user and returns a `MatrixClient` logged in and ready to use.
|
||||
* Registers a test user and returns a `MatrixClient` logged in and ready to use.
|
||||
*
|
||||
* @param isAdmin Whether to make the user an admin.
|
||||
* @param label If specified, a string to place somewhere within the username.
|
||||
* @returns A new `MatrixClient` session for a unique test user.
|
||||
*/
|
||||
export async function newTestUser(isAdmin: boolean = false, label: string = ""): Promise<MatrixClient> {
|
||||
const { username, access_token } = await registerNewTestUser(isAdmin, label);
|
||||
export async function newTestUser(options: RegistrationOptions): Promise<MatrixClient> {
|
||||
const { username, access_token } = await registerNewTestUser(options);
|
||||
let client: MatrixClient;
|
||||
if (config.pantalaimon.use) {
|
||||
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
|
||||
return await pantalaimon.createClientWithCredentials(username, username);
|
||||
client = await pantalaimon.createClientWithCredentials(username, username);
|
||||
} else {
|
||||
const client = new MatrixClient(config.homeserverUrl, access_token, new MemoryStorageProvider(), await getTempCryptoStore());
|
||||
client = new MatrixClient(config.homeserverUrl, access_token, new MemoryStorageProvider(), await getTempCryptoStore());
|
||||
client.crypto.prepare(await client.getJoinedRooms());
|
||||
}
|
||||
if (!options.isThrottled) {
|
||||
let userId = await client.getUserId();
|
||||
await overrideRatelimitForUser(userId);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
let _globalAdminUser: MatrixClient;
|
||||
|
||||
/**
|
||||
* Get a client that can perform synapse admin API actions.
|
||||
* @returns A client logged in with an admin user.
|
||||
*/
|
||||
async function getGlobalAdminUser(): Promise<MatrixClient> {
|
||||
// Initialize global admin user if needed.
|
||||
if (!_globalAdminUser) {
|
||||
const USERNAME = "mjolnir-test-internal-admin-user";
|
||||
try {
|
||||
await registerUser(USERNAME, USERNAME, USERNAME, true);
|
||||
} catch (e) {
|
||||
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
|
||||
// Then we've already registered the user in a previous run and that is ok.
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
_globalAdminUser = await new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider()).createClientWithCredentials(USERNAME, USERNAME);
|
||||
}
|
||||
return _globalAdminUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable ratelimiting for this user in Synapse.
|
||||
* @param userId The user to disable ratelimiting for, has to include both the server part and local part.
|
||||
*/
|
||||
export async function overrideRatelimitForUser(userId: string) {
|
||||
await (await getGlobalAdminUser()).doRequest("POST", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null, {
|
||||
"messages_per_second": 0,
|
||||
"burst_count": 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Put back the default ratelimiting for this user in Synapse.
|
||||
* @param userId The user to use default ratelimiting for, has to include both the server part and local part.
|
||||
*/
|
||||
export async function resetRatelimitForUser(userId: string) {
|
||||
await (await getGlobalAdminUser()).doRequest("DELETE", `/_synapse/admin/v1/users/${userId}/override_ratelimit`, null);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Utility to create an event listener for m.notice msgtype m.room.messages.
|
||||
* @param targetRoomdId The roomId to listen into.
|
||||
|
@ -1,19 +1,20 @@
|
||||
import { MatrixClient } from "matrix-bot-sdk";
|
||||
import { strict as assert } from "assert";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk.
|
||||
* @param client A MatrixClient that is already in the targetRoom that can be started to listen for the event produced by targetEventThunk.
|
||||
* Returns a promise that resolves to the first event replying to the event produced by targetEventThunk.
|
||||
* @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 reaction in.
|
||||
* @param reactionKey The reaction key to wait for.
|
||||
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction.
|
||||
* @returns The reaction event.
|
||||
* @param targetRoom The room to listen for the reply in.
|
||||
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reply.
|
||||
* @returns The replying event.
|
||||
*/
|
||||
export async function onReactionTo(client: MatrixClient, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise<string>): Promise<any> {
|
||||
export async function getFirstReply(client: MatrixClient, targetRoom: string, targetEventThunk: () => Promise<string>): Promise<any> {
|
||||
let reactionEvents = [];
|
||||
const addEvent = function (roomId, event) {
|
||||
if (roomId !== targetRoom) return;
|
||||
if (event.type !== 'm.reaction') return;
|
||||
if (event.type !== 'm.room.message') return;
|
||||
reactionEvents.push(event);
|
||||
};
|
||||
let targetCb;
|
||||
@ -21,17 +22,17 @@ export async function onReactionTo(client: MatrixClient, targetRoom: string, rea
|
||||
client.on('room.event', addEvent)
|
||||
const targetEventId = await targetEventThunk();
|
||||
for (let event of reactionEvents) {
|
||||
const relates_to = event.content['m.relates_to'];
|
||||
if (relates_to.event_id === targetEventId && relates_to.key === reactionKey) {
|
||||
const in_reply_to = event.content['m.relates_to']?.['m.in_reply_to'];
|
||||
if (in_reply_to?.event_id === targetEventId) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
return await new Promise(resolve => {
|
||||
targetCb = function(roomId, event) {
|
||||
if (roomId !== targetRoom) return;
|
||||
if (event.type !== 'm.reaction') return;
|
||||
const relates_to = event.content['m.relates_to'];
|
||||
if (relates_to.event_id === targetEventId && relates_to.key === reactionKey) {
|
||||
if (event.type !== 'm.room.message') return;
|
||||
const in_reply_to = event.content['m.relates_to']?.['m.in_reply_to'];
|
||||
if (in_reply_to?.event_id === targetEventId) {
|
||||
resolve(event)
|
||||
}
|
||||
}
|
||||
@ -44,3 +45,66 @@ export async function onReactionTo(client: MatrixClient, targetRoom: string, rea
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to an event that is reacting to the event produced by targetEventThunk.
|
||||
* @param client A MatrixClient that is already in the targetRoom that can be started 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 reaction in.
|
||||
* @param reactionKey The reaction key to wait for.
|
||||
* @param targetEventThunk A function that produces an event ID when called. This event ID is then used to listen for a reaction.
|
||||
* @returns The reaction event.
|
||||
*/
|
||||
export async function getFirstReaction(client: MatrixClient, targetRoom: string, reactionKey: string, targetEventThunk: () => Promise<string>): Promise<any> {
|
||||
let reactionEvents = [];
|
||||
const addEvent = function (roomId, event) {
|
||||
if (roomId !== targetRoom) return;
|
||||
if (event.type !== 'm.reaction') return;
|
||||
reactionEvents.push(event);
|
||||
};
|
||||
let targetCb;
|
||||
try {
|
||||
client.on('room.event', addEvent)
|
||||
const targetEventId = await targetEventThunk();
|
||||
for (let event of reactionEvents) {
|
||||
const relates_to = event.content['m.relates_to'];
|
||||
if (relates_to?.event_id === targetEventId && relates_to?.key === reactionKey) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
return await new Promise((resolve, reject) => {
|
||||
targetCb = function(roomId, event) {
|
||||
if (roomId !== targetRoom) return;
|
||||
if (event.type !== 'm.reaction') return;
|
||||
const relates_to = event.content['m.relates_to'];
|
||||
if (relates_to?.event_id === targetEventId && relates_to?.key === reactionKey) {
|
||||
resolve(event)
|
||||
}
|
||||
}
|
||||
client.on('room.event', targetCb);
|
||||
});
|
||||
} finally {
|
||||
client.removeListener('room.event', addEvent);
|
||||
if (targetCb) {
|
||||
client.removeListener('room.event', targetCb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new banlist for mjolnir to watch and return the shortcode that can be used to refer to the list in future commands.
|
||||
* @param managementRoom The room to send the create command to.
|
||||
* @param mjolnir A syncing matrix client.
|
||||
* @param client A client that isn't mjolnir to send the message with, as you will be invited to the room.
|
||||
* @returns The shortcode for the list that can be used to refer to the list in future commands.
|
||||
*/
|
||||
export async function createBanList(managementRoom: string, mjolnir: MatrixClient, client: MatrixClient): Promise<string> {
|
||||
const listName = crypto.randomUUID();
|
||||
const listCreationResponse = await getFirstReply(mjolnir, managementRoom, async () => {
|
||||
return await client.sendMessage(managementRoom, { msgtype: 'm.text', body: `!mjolnir list create ${listName} ${listName}`});
|
||||
});
|
||||
assert.equal(listCreationResponse.content.body.includes('This list is now being watched.'), true, 'could not create a list to test with.');
|
||||
return listName;
|
||||
}
|
||||
|
@ -4,17 +4,20 @@ import config from "../../../src/config";
|
||||
import { newTestUser } from "../clientHelper";
|
||||
import { getMessagesByUserIn } from "../../../src/utils";
|
||||
import { LogService } from "matrix-bot-sdk";
|
||||
import { onReactionTo } from "./commandUtils";
|
||||
import { getFirstReaction } from "./commandUtils";
|
||||
|
||||
describe("Test: The redaction command", function () {
|
||||
// If a test has a timeout while awaitng on a promise then we never get given control back.
|
||||
afterEach(function() { this.moderator?.stop(); });
|
||||
|
||||
it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id and a room id.', async function() {
|
||||
this.timeout(60000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser(false, "spammer-needs-redacting");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
|
||||
let badUserId = await badUser.getUserId();
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let mjolnirUserId = await mjolnir.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" } });
|
||||
this.moderator = moderator;
|
||||
await moderator.joinRoom(config.managementRoom);
|
||||
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
|
||||
@ -33,8 +36,8 @@ import { onReactionTo } from "./commandUtils";
|
||||
await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"});
|
||||
|
||||
try {
|
||||
moderator.start();
|
||||
await onReactionTo(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
await moderator.start();
|
||||
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId} ${targetRoom}` });
|
||||
});
|
||||
} finally {
|
||||
@ -51,14 +54,15 @@ import { onReactionTo } from "./commandUtils";
|
||||
})
|
||||
});
|
||||
})
|
||||
|
||||
it('Mjölnir redacts all of the events sent by a spammer when instructed to by giving their id in multiple rooms.', async function() {
|
||||
this.timeout(60000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser(false, "spammer-needs-redacting");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
|
||||
let badUserId = await badUser.getUserId();
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let mjolnirUserId = await mjolnir.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" } });
|
||||
this.moderator = moderator;
|
||||
await moderator.joinRoom(config.managementRoom);
|
||||
let targetRooms: string[] = [];
|
||||
@ -80,8 +84,8 @@ import { onReactionTo } from "./commandUtils";
|
||||
}
|
||||
|
||||
try {
|
||||
moderator.start();
|
||||
await onReactionTo(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
await moderator.start();
|
||||
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId}` });
|
||||
});
|
||||
} finally {
|
||||
@ -103,10 +107,10 @@ import { onReactionTo } from "./commandUtils";
|
||||
it("Redacts a single event when instructed to.", async function () {
|
||||
this.timeout(60000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser(false, "spammer-needs-redacting");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let mjolnirUserId = await mjolnir.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" } });
|
||||
this.moderator = moderator;
|
||||
await moderator.joinRoom(config.managementRoom);
|
||||
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
|
||||
@ -116,8 +120,8 @@ import { onReactionTo } from "./commandUtils";
|
||||
let eventToRedact = await badUser.sendMessage(targetRoom, {msgtype: 'm.text', body: "Very Bad Stuff"});
|
||||
|
||||
try {
|
||||
moderator.start();
|
||||
await onReactionTo(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
await moderator.start();
|
||||
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir redact https://matrix.to/#/${encodeURIComponent(targetRoom)}/${encodeURIComponent(eventToRedact)}`});
|
||||
});
|
||||
} finally {
|
||||
|
@ -11,10 +11,12 @@ export const mochaHooks = {
|
||||
beforeEach: [
|
||||
async function() {
|
||||
console.log("mochaHooks.beforeEach");
|
||||
// Sometimes it takes a little longer to register users.
|
||||
this.timeout(3000)
|
||||
this.managementRoomAlias = config.managementRoom;
|
||||
this.mjolnir = await makeMjolnir();
|
||||
config.RUNTIME.client = this.mjolnir.client;
|
||||
this.mjolnir.start();
|
||||
await this.mjolnir.start();
|
||||
console.log("mochaHooks.beforeEach DONE");
|
||||
}
|
||||
],
|
||||
|
@ -4,7 +4,7 @@ import { newTestUser, noticeListener } from "./clientHelper"
|
||||
describe("Test: !help command", function() {
|
||||
let client;
|
||||
this.beforeEach(async function () {
|
||||
client = await newTestUser(true);
|
||||
client = await newTestUser({ name: { contains: "-" }});;
|
||||
await client.start();
|
||||
})
|
||||
this.afterEach(async function () {
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
} from "matrix-bot-sdk";
|
||||
import { Mjolnir } from '../../src/Mjolnir';
|
||||
import config from "../../src/config";
|
||||
import { getTempCryptoStore, registerUser } from "./clientHelper";
|
||||
import { getTempCryptoStore, overrideRatelimitForUser, registerUser } from "./clientHelper";
|
||||
import { patchMatrixClient } from "../../src/utils";
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
@ -52,7 +52,12 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
|
||||
|
||||
async function configureMjolnir() {
|
||||
try {
|
||||
const { access_token } = await registerUser('mjolnir', 'mjolnir', 'mjolnir', true);
|
||||
const { access_token } = await registerUser(
|
||||
config.pantalaimon.username,
|
||||
config.pantalaimon.username,
|
||||
config.pantalaimon.password,
|
||||
true
|
||||
);
|
||||
return access_token;
|
||||
} catch (e) {
|
||||
if (e.isAxiosError) {
|
||||
@ -99,6 +104,7 @@ export async function makeMjolnir(): Promise<Mjolnir> {
|
||||
client = new MatrixClient(config.homeserverUrl, accessToken, new MemoryStorageProvider(), await getTempCryptoStore());
|
||||
client.crypto.prepare(await client.getJoinedRooms());
|
||||
}
|
||||
await overrideRatelimitForUser(await client.getUserId());
|
||||
patchMatrixClient();
|
||||
await ensureAliasedRoomExists(client, config.managementRoom);
|
||||
let mjolnir = await Mjolnir.setupMjolnirFromConfig(client);
|
||||
|
158
test/integration/policyConsumptionTest.ts
Normal file
158
test/integration/policyConsumptionTest.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import { newTestUser } from "./clientHelper";
|
||||
import config from "../../src/config";
|
||||
import { getRequestFn, LogService, MatrixClient } from "matrix-bot-sdk";
|
||||
import { createBanList, getFirstReaction } from "./commands/commandUtils";
|
||||
|
||||
/**
|
||||
* Get a copy of the rules from the ruleserver.
|
||||
*/
|
||||
async function currentRules(): Promise<{ start: object, stop: object, since: string }> {
|
||||
return await new Promise((resolve, reject) => getRequestFn()({
|
||||
uri: `http://${config.web.address}:${config.web.port}/api/1/ruleserver/updates/`,
|
||||
method: "GET"
|
||||
}, (error, response, body) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(JSON.parse(body))
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the rules to change as a result of the thunk. The returned promise will resolve when the rules being served have changed.
|
||||
* @param thunk Should cause the rules the RuleServer is serving to change some way.
|
||||
*/
|
||||
async function waitForRuleChange(thunk): Promise<void> {
|
||||
const initialRules = await currentRules();
|
||||
let rules = initialRules;
|
||||
// We use JSON.stringify like this so that it is pretty printed in the log and human readable.
|
||||
LogService.debug('policyConsumptionTest', `Rules before we wait for them to change: ${JSON.stringify(rules, null, 2)}`);
|
||||
await thunk();
|
||||
while (rules.since === initialRules.since) {
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 500);
|
||||
})
|
||||
rules = await currentRules();
|
||||
};
|
||||
// The problem is, we have no idea how long a consumer will take to process the changed rules.
|
||||
// We know the pull peroid is 1 second though.
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 1500);
|
||||
})
|
||||
LogService.debug('policyConsumptionTest', `Rules after they have changed: ${JSON.stringify(rules, null, 2)}`);
|
||||
}
|
||||
|
||||
describe("Test: that policy lists are consumed by the associated synapse module", function () {
|
||||
this.afterEach(async function () {
|
||||
if(config.web.ruleServer.enabled) {
|
||||
this.timeout(5000)
|
||||
LogService.debug('policyConsumptionTest', `Rules at end of test ${JSON.stringify(await currentRules(), null, 2)}`);
|
||||
const mjolnir = config.RUNTIME.client!;
|
||||
// Clear any state associated with the account.
|
||||
await mjolnir.setAccountData('org.matrix.mjolnir.watched_lists', {
|
||||
references: [],
|
||||
});
|
||||
}
|
||||
})
|
||||
this.beforeAll(async function() {
|
||||
if (!config.web.ruleServer.enabled) {
|
||||
LogService.warn("policyConsumptionTest", "Skipping policy consumption test because the ruleServer is not enabled")
|
||||
this.skip();
|
||||
}
|
||||
})
|
||||
this.beforeEach(async function () {
|
||||
this.timeout(1000);
|
||||
const mjolnir = config.RUNTIME.client!;
|
||||
})
|
||||
it('blocks users in antispam when they are banned from sending messages and invites serverwide.', async function() {
|
||||
this.timeout(20000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser({ name: { contains: "spammer" }});
|
||||
let badUserId = await badUser.getUserId();
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let mjolnirUserId = await mjolnir.getUserId();
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
this.moderator = moderator;
|
||||
await moderator.joinRoom(this.mjolnir.managementRoomId);
|
||||
let unprotectedRoom = await badUser.createRoom({ invite: [await moderator.getUserId()]});
|
||||
// We do this so the moderator can send invites, no other reason.
|
||||
await badUser.setUserPowerLevel(await moderator.getUserId(), unprotectedRoom, 100);
|
||||
await moderator.joinRoom(unprotectedRoom);
|
||||
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
|
||||
await badUser.sendMessage(unprotectedRoom, {msgtype: 'm.text', body: 'Something bad and mean'});
|
||||
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${badUserId}` });
|
||||
});
|
||||
});
|
||||
await assert.rejects(badUser.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}), 'The bad user should be banned and unable to send messages.');
|
||||
await assert.rejects(badUser.inviteUser(mjolnirUserId, unprotectedRoom), 'They should also be unable to send invitations.');
|
||||
assert.ok(await moderator.inviteUser('@test:localhost:9999', unprotectedRoom), 'The moderator is not banned though so should still be able to invite');
|
||||
assert.ok(await moderator.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}), 'They should be able to send messages still too.');
|
||||
|
||||
// Test we can remove the rules.
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${banList} ${badUserId}` });
|
||||
});
|
||||
});
|
||||
assert.ok(await badUser.sendMessage(unprotectedRoom, { msgtype: 'm.text', body: 'test'}));
|
||||
assert.ok(await badUser.inviteUser(mjolnirUserId, unprotectedRoom));
|
||||
})
|
||||
it('Test: Cannot send message to a room that is listed in a policy list and cannot invite a user to the room either', async function () {
|
||||
this.timeout(20000);
|
||||
let badUser = await newTestUser({ name: { contains: "spammer" }});
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
await moderator.joinRoom(this.mjolnir.managementRoomId);
|
||||
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
|
||||
let badRoom = await badUser.createRoom();
|
||||
let unrelatedRoom = await badUser.createRoom();
|
||||
await badUser.sendMessage(badRoom, {msgtype: 'm.text', body: "Very Bad Stuff in this room"});
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${badRoom}` });
|
||||
});
|
||||
});
|
||||
await assert.rejects(badUser.sendMessage(badRoom, { msgtype: 'm.text', body: 'test'}), 'should not be able to send messagea to a room which is listed.');
|
||||
await assert.rejects(badUser.inviteUser(await moderator.getUserId(), badRoom), 'should not be able to invite people to a listed room.');
|
||||
assert.ok(await badUser.sendMessage(unrelatedRoom, { msgtype: 'm.text.', body: 'hey'}), 'should be able to send messages to unrelated room');
|
||||
assert.ok(await badUser.inviteUser(await moderator.getUserId(), unrelatedRoom), 'They should still be able to invite to other rooms though');
|
||||
// Test we can remove these rules.
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unban ${banList} ${badRoom}` });
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(await badUser.sendMessage(badRoom, { msgtype: 'm.text', body: 'test'}), 'should now be able to send messages to the room.');
|
||||
assert.ok(await badUser.inviteUser(await moderator.getUserId(), badRoom), 'should now be able to send messages to the room.');
|
||||
})
|
||||
it('Test: When a list becomes unwatched, the associated policies are stopped.', async function () {
|
||||
this.timeout(20000);
|
||||
const mjolnir = config.RUNTIME.client!
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
await moderator.joinRoom(this.mjolnir.managementRoomId);
|
||||
const banList = await createBanList(this.mjolnir.managementRoomId, mjolnir, moderator);
|
||||
let targetRoom = await moderator.createRoom();
|
||||
await moderator.sendMessage(targetRoom, {msgtype: 'm.text', body: "Fluffy Foxes."});
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir ban ${banList} ${targetRoom}` });
|
||||
});
|
||||
});
|
||||
await assert.rejects(moderator.sendMessage(targetRoom, { msgtype: 'm.text', body: 'test'}), 'should not be able to send messages to a room which is listed.');
|
||||
|
||||
await waitForRuleChange(async () => {
|
||||
await getFirstReaction(mjolnir, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir unwatch #${banList}:localhost:9999` });
|
||||
});
|
||||
});
|
||||
|
||||
assert.ok(await moderator.sendMessage(targetRoom, { msgtype: 'm.text', body: 'test'}), 'should now be able to send messages to the room.');
|
||||
})
|
||||
});
|
163
test/integration/protectionSettingsTest.ts
Normal file
163
test/integration/protectionSettingsTest.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import config from "../../src/config";
|
||||
import { Mjolnir } from "../../src/Mjolnir";
|
||||
import { IProtection } from "../../src/protections/IProtection";
|
||||
import { PROTECTIONS } from "../../src/protections/protections";
|
||||
import { ProtectionSettingValidationError } from "../../src/protections/ProtectionSettings";
|
||||
import { NumberProtectionSetting, StringProtectionSetting, StringListProtectionSetting } from "../../src/protections/ProtectionSettings";
|
||||
import { newTestUser, noticeListener } from "./clientHelper";
|
||||
import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
|
||||
|
||||
describe("Test: Protection settings", function() {
|
||||
let client;
|
||||
this.beforeEach(async function () {
|
||||
client = await newTestUser({ name: { contains: "protection-settings" }});
|
||||
await client.start();
|
||||
})
|
||||
this.afterEach(async function () {
|
||||
await client.stop();
|
||||
})
|
||||
it("Mjolnir refuses to save invalid protection setting values", async function() {
|
||||
this.timeout(20000);
|
||||
await assert.rejects(
|
||||
async () => await this.mjolnir.setProtectionSettings("BasicFloodingProtection", {"maxPerMinute": "soup"}),
|
||||
ProtectionSettingValidationError
|
||||
);
|
||||
});
|
||||
it("Mjolnir successfully saves valid protection setting values", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "05OVMS";
|
||||
description = "A test protection";
|
||||
settings = { test: new NumberProtectionSetting(3) };
|
||||
});
|
||||
|
||||
await this.mjolnir.setProtectionSettings("05OVMS", { test: 123 });
|
||||
assert.equal(
|
||||
(await this.mjolnir.getProtectionSettings("05OVMS"))["test"],
|
||||
123
|
||||
);
|
||||
});
|
||||
it("Mjolnir should accumulate changed settings", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "HPUjKN";
|
||||
settings = {
|
||||
test1: new NumberProtectionSetting(3),
|
||||
test2: new NumberProtectionSetting(4)
|
||||
};
|
||||
});
|
||||
|
||||
await this.mjolnir.setProtectionSettings("HPUjKN", { test1: 1 });
|
||||
await this.mjolnir.setProtectionSettings("HPUjKN", { test2: 2 });
|
||||
const settings = await this.mjolnir.getProtectionSettings("HPUjKN");
|
||||
assert.equal(settings["test1"], 1);
|
||||
assert.equal(settings["test2"], 2);
|
||||
});
|
||||
it("Mjolnir responds to !set correctly", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "JY2TPN";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringProtectionSetting() };
|
||||
});
|
||||
|
||||
|
||||
let reply = new Promise((resolve, reject) => {
|
||||
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
|
||||
if (event.content.body.includes("Changed JY2TPN.test ")) {
|
||||
resolve(event);
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set JY2TPN.test asd"})
|
||||
await reply
|
||||
|
||||
const settings = await this.mjolnir.getProtectionSettings("JY2TPN");
|
||||
assert.equal(settings["test"], "asd");
|
||||
});
|
||||
it("Mjolnir adds a value to a list setting", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "r33XyT";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringListProtectionSetting() };
|
||||
});
|
||||
|
||||
|
||||
let reply = new Promise((resolve, reject) => {
|
||||
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
|
||||
if (event.content.body.includes("Changed r33XyT.test ")) {
|
||||
resolve(event);
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add r33XyT.test asd"})
|
||||
await reply
|
||||
|
||||
assert.deepEqual(await this.mjolnir.getProtectionSettings("r33XyT"), { "test": ["asd"] });
|
||||
});
|
||||
it("Mjolnir removes a value from a list setting", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "oXzT0E";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringListProtectionSetting() };
|
||||
});
|
||||
|
||||
let reply = () => new Promise((resolve, reject) => {
|
||||
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
|
||||
if (event.content.body.includes("Changed oXzT0E.test ")) {
|
||||
resolve(event);
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config add oXzT0E.test asd"})
|
||||
await reply();
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config remove oXzT0E.test asd"})
|
||||
await reply();
|
||||
|
||||
assert.deepEqual(await this.mjolnir.getProtectionSettings("oXzT0E"), { "test": [] });
|
||||
});
|
||||
it("Mjolnir will change a protection setting in-place", async function() {
|
||||
this.timeout(20000);
|
||||
await client.joinRoom(config.managementRoom);
|
||||
|
||||
await this.mjolnir.registerProtection(new class implements IProtection {
|
||||
name = "d0sNrt";
|
||||
description = "A test protection";
|
||||
settings = { test: new StringProtectionSetting() };
|
||||
});
|
||||
|
||||
let replyPromise = new Promise((resolve, reject) => {
|
||||
let i = 0;
|
||||
client.on('room.message', noticeListener(this.mjolnir.managementRoomId, (event) => {
|
||||
if (event.content.body.includes("Changed d0sNrt.test ")) {
|
||||
if (++i == 2) {
|
||||
resolve(event);
|
||||
}
|
||||
}
|
||||
}))
|
||||
});
|
||||
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd1"})
|
||||
await client.sendMessage(this.mjolnir.managementRoomId, {msgtype: "m.text", body: "!mjolnir config set d0sNrt.test asd2"})
|
||||
assert.equal(
|
||||
(await replyPromise).content.body.split("\n", 3)[2],
|
||||
"Changed d0sNrt.test to asd2 (was asd1)"
|
||||
)
|
||||
});
|
||||
});
|
||||
|
77
test/integration/throttleTest.ts
Normal file
77
test/integration/throttleTest.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { strict as assert } from "assert";
|
||||
import { newTestUser, overrideRatelimitForUser, resetRatelimitForUser } from "./clientHelper";
|
||||
import { getMessagesByUserIn } from "../../src/utils";
|
||||
import { getFirstReaction } from "./commands/commandUtils";
|
||||
|
||||
describe("Test: throttled users can function with Mjolnir.", function () {
|
||||
it('throttled users survive being throttled by synapse', async function() {
|
||||
this.timeout(60000);
|
||||
let throttledUser = await newTestUser({ name: { contains: "throttled" }, isThrottled: true });
|
||||
let throttledUserId = await throttledUser.getUserId();
|
||||
let targetRoom = await throttledUser.createRoom();
|
||||
// send enough messages to hit the rate limit.
|
||||
await Promise.all([...Array(150).keys()].map((i) => throttledUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Message #${i}`})));
|
||||
let messageCount = 0;
|
||||
await getMessagesByUserIn(throttledUser, throttledUserId, targetRoom, 150, (events) => {
|
||||
messageCount += events.length;
|
||||
});
|
||||
assert.equal(messageCount, 150, "There should have been 150 messages in this room");
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test: Mjolnir can still sync and respond to commands while throttled", function () {
|
||||
beforeEach(async function() {
|
||||
await resetRatelimitForUser(await this.mjolnir.client.getUserId())
|
||||
})
|
||||
afterEach(async function() {
|
||||
// If a test has a timeout while awaitng on a promise then we never get given control back.
|
||||
this.moderator?.stop();
|
||||
|
||||
await overrideRatelimitForUser(await this.mjolnir.client.getUserId());
|
||||
})
|
||||
|
||||
it('Can still perform and respond to a redaction command', async function () {
|
||||
this.timeout(60000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser({ name: { contains: "spammer-needs-redacting" } });
|
||||
let badUserId = await badUser.getUserId();
|
||||
const mjolnir = this.mjolnir.client;
|
||||
let mjolnirUserId = await mjolnir.getUserId();
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" } });
|
||||
this.moderator = moderator;
|
||||
await moderator.joinRoom(this.mjolnir.managementRoomId);
|
||||
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId(), mjolnirUserId]});
|
||||
await moderator.setUserPowerLevel(mjolnirUserId, targetRoom, 100);
|
||||
await badUser.joinRoom(targetRoom);
|
||||
|
||||
// Give Mjolnir some work to do and some messages to sync through.
|
||||
await Promise.all([...Array(100).keys()].map((i) => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
|
||||
await Promise.all([...Array(50).keys()].map(_ => moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: '!mjolnir status'})));
|
||||
|
||||
await moderator.sendMessage(this.mjolnir.managementRoomId, {msgtype: 'm.text', body: `!mjolnir rooms add ${targetRoom}`});
|
||||
|
||||
await Promise.all([...Array(50).keys()].map((i) => badUser.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Bad Message #${i}`})));
|
||||
|
||||
try {
|
||||
await moderator.start();
|
||||
await getFirstReaction(moderator, this.mjolnir.managementRoomId, '✅', async () => {
|
||||
return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir redact ${badUserId} ${targetRoom}` });
|
||||
});
|
||||
} finally {
|
||||
moderator.stop();
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
await getMessagesByUserIn(moderator, badUserId, targetRoom, 1000, function(events) {
|
||||
count += events.length
|
||||
events.map(e => {
|
||||
if (e.type === 'm.room.member') {
|
||||
assert.equal(Object.keys(e.content).length, 1, "Only membership should be left on the membership event when it has been redacted.")
|
||||
} else if (Object.keys(e.content).length !== 0) {
|
||||
throw new Error(`This event should have been redacted: ${JSON.stringify(e, null, 2)}`)
|
||||
}
|
||||
})
|
||||
});
|
||||
assert.equal(count, 51, "There should be exactly 51 events from the spammer in this room.");
|
||||
})
|
||||
})
|
@ -10,9 +10,9 @@ describe("Test: timeline pagination", function () {
|
||||
it('does not paginate across the entire room history while backfilling.', async function() {
|
||||
this.timeout(60000);
|
||||
// Create a few users and a room.
|
||||
let badUser = await newTestUser(false, "spammer");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer" }});
|
||||
let badUserId = await badUser.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]});
|
||||
await badUser.joinRoom(targetRoom);
|
||||
|
||||
@ -39,9 +39,9 @@ describe("Test: timeline pagination", function () {
|
||||
})
|
||||
it('does not call the callback with an empty array when there are no relevant events', async function() {
|
||||
this.timeout(60000);
|
||||
let badUser = await newTestUser(false, "spammer");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer" }});
|
||||
let badUserId = await badUser.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
let targetRoom = await moderator.createRoom();
|
||||
// send some irrelevant messages
|
||||
await Promise.all([...Array(200).keys()].map((i) => moderator.sendMessage(targetRoom, {msgtype: 'm.text.', body: `Irrelevant Message #${i}`})));
|
||||
@ -54,9 +54,9 @@ describe("Test: timeline pagination", function () {
|
||||
})
|
||||
it("The limit provided is respected", async function() {
|
||||
this.timeout(60000);
|
||||
let badUser = await newTestUser(false, "spammer");
|
||||
let badUser = await newTestUser({ name: { contains: "spammer" }});
|
||||
let badUserId = await badUser.getUserId();
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
let targetRoom = await moderator.createRoom({ invite: [await badUser.getUserId()]});
|
||||
await badUser.joinRoom(targetRoom);
|
||||
// send some bad person messages
|
||||
@ -83,7 +83,7 @@ describe("Test: timeline pagination", function () {
|
||||
});
|
||||
it("Gives the events to the callback ordered by youngest first (even more important when the limit is reached halfway through a chunk).", async function() {
|
||||
this.timeout(60000);
|
||||
let moderator = await newTestUser(false, "moderator");
|
||||
let moderator = await newTestUser({ name: { contains: "moderator" }});
|
||||
let moderatorId = await moderator.getUserId();
|
||||
let targetRoom = await moderator.createRoom();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
|
31
test/integration/utilsTest.ts
Normal file
31
test/integration/utilsTest.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { strict as assert } from "assert";
|
||||
|
||||
import { UserID } from "matrix-bot-sdk";
|
||||
import config from "../../src/config";
|
||||
import { replaceRoomIdsWithPills } from "../../src/utils";
|
||||
|
||||
describe("Test: utils", function() {
|
||||
it("replaceRoomIdsWithPills correctly turns a room ID in to a pill", async function() {
|
||||
this.timeout(20000);
|
||||
|
||||
await this.mjolnir.client.sendStateEvent(
|
||||
this.mjolnir.managementRoomId,
|
||||
"m.room.canonical_alias",
|
||||
"",
|
||||
{ alias: config.managementRoom }
|
||||
);
|
||||
|
||||
const out = await replaceRoomIdsWithPills(
|
||||
this.mjolnir.client,
|
||||
`it's fun here in ${this.mjolnir.managementRoomId}`,
|
||||
new Set([this.mjolnir.managementRoomId])
|
||||
);
|
||||
|
||||
const ourHomeserver = new UserID(await this.mjolnir.client.getUserId()).domain;
|
||||
assert.equal(
|
||||
out.formatted_body,
|
||||
`it's fun here in <a href="https://matrix.to/#/${config.managementRoom}?via=${ourHomeserver}">${config.managementRoom}</a>`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -9,7 +9,7 @@
|
||||
"noImplicitReturns": true,
|
||||
"noUnusedLocals": true,
|
||||
"target": "es2015",
|
||||
"noImplicitAny": false,
|
||||
"noImplicitAny": true,
|
||||
"sourceMap": true,
|
||||
"strictNullChecks": true,
|
||||
"outDir": "./lib",
|
||||
|
15
yarn.lock
15
yarn.lock
@ -88,6 +88,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
||||
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
|
||||
|
||||
<<<<<<< HEAD
|
||||
"@turt2live/matrix-sdk-crypto-nodejs@^0.1.0-beta.8":
|
||||
version "0.1.0-beta.8"
|
||||
resolved "https://registry.yarnpkg.com/@turt2live/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.8.tgz#a243a91183ebef6ede11954d6f0f668664f15554"
|
||||
@ -103,6 +104,8 @@
|
||||
dependencies:
|
||||
axios "*"
|
||||
|
||||
=======
|
||||
>>>>>>> origin/main
|
||||
"@types/body-parser@*":
|
||||
version "1.19.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.1.tgz#0c0174c42a7d017b818303d4b5d969cb0b75929c"
|
||||
@ -404,13 +407,6 @@ aws4@^1.8.0:
|
||||
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
|
||||
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
|
||||
|
||||
axios@*, axios@^0.21.4:
|
||||
version "0.21.4"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
|
||||
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
|
||||
dependencies:
|
||||
follow-redirects "^1.14.0"
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
@ -1141,11 +1137,6 @@ flatted@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
|
||||
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
|
||||
|
||||
follow-redirects@^1.14.0:
|
||||
version "1.14.7"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
|
||||
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
|
||||
|
||||
forever-agent@~0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
|
||||
|
Loading…
Reference in New Issue
Block a user