Merge remote-tracking branch 'origin/main' into hs/native-e2e

This commit is contained in:
Half-Shot 2022-02-14 10:43:50 +00:00
commit e6083c310f
54 changed files with 2149 additions and 315 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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
};

View File

@ -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,

View File

@ -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);
}
}
}

View File

@ -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)}`);

View File

@ -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[]) {

View File

@ -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" +

View File

@ -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",

View File

@ -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) {

View File

@ -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>";

View File

@ -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");

View File

@ -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.

View File

@ -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;

View File

@ -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
View 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 []
}
}

View File

@ -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,
};
}

View File

@ -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);
}
}
}

View File

@ -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] = [];

View File

@ -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);
}
}

View File

@ -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') {

View File

@ -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']) {

View 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))
}
}

View 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(', ')}`
});
}
}
}

View File

@ -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> {

View File

@ -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()
];

View File

@ -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) {

View File

@ -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)]: "&quot;",
["&".charCodeAt(0)]: "&amp;",
["<".charCodeAt(0)]: "&lt;",
[">".charCodeAt(0)]: "&gt;"
})[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();
}

View File

@ -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);
}
}
}

View File

@ -1 +1,2 @@
from .antispam import AntiSpam
from .antispam import Module

View File

@ -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)

View File

@ -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]
)

View File

@ -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

View 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)

View File

@ -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,

View File

@ -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();

View File

@ -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);
})
})

View File

@ -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.

View File

@ -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;
}

View File

@ -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 {

View File

@ -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");
}
],

View File

@ -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 () {

View File

@ -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);

View 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.');
})
});

View 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)"
)
});
});

View 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.");
})
})

View File

@ -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++) {

View 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>`
);
});
});

View File

@ -9,7 +9,7 @@
"noImplicitReturns": true,
"noUnusedLocals": true,
"target": "es2015",
"noImplicitAny": false,
"noImplicitAny": true,
"sourceMap": true,
"strictNullChecks": true,
"outDir": "./lib",

View File

@ -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"