Give the ability to moderators to react quickly to /report abuse reports. (#137)

This commit is contained in:
David Teller 2021-11-09 13:15:49 +01:00 committed by GitHub
parent e7195678d4
commit a21415a04c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1288 additions and 169 deletions

25
.github/workflows/mjolnir.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Mjolnir
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: install mx-tester
run: cargo install mx-tester
- name: Setup image
run: RUST_LOG=debug mx-tester build up
- name: Setup dependencies
run: yarn install
- name: Run tests
run: RUST_LOG=debug mx-tester run

View File

@ -1,69 +1,14 @@
name: Mjolnir Testing name: mjolnir
modules:
- name: synapse_antispam
build:
- cp -r synapse_antispam $MX_TEST_SYNAPSE_DIR
up: up:
# Launch the reverse proxy, listening for connections *only* on the local host. # Launch the reverse proxy, listening for connections *only* on the local host.
- docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx - docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx
run: run:
- yarn test:integration - yarn test:integration
down: down:
finally: finally:
- docker stop mjolnir-test-reverse-proxy - docker stop mjolnir-test-reverse-proxy || true
homeserver_config: homeserver:
server_name: localhost:9999 server_name: localhost:9999
pid_file: /data/homeserver.pid
public_baseurl: http://localhost:9999 public_baseurl: http://localhost:9999
listeners: registration_shared_secret: REGISTRATION_SHARED_SECRET
- port: 9999
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: "/data/media_store"
enable_registration: true
report_stats: false
registration_shared_secret: "REGISTRATION_SHARED_SECRET"
macaroon_secret_key: "MACROON_SECRET_KEY"
signing_key_path: "/data/localhost:9999.signing.key"
trusted_key_servers:
- server_name: "matrix.org"
suppress_key_server_warning: true
rc_message:
per_second: 10000
burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
rc_admin_redaction:
per_second: 10000
burst_count: 10000
rc_joins:
local:
per_second: 10000
burst_count: 10000
remote:
per_second: 10000
burst_count: 10000

View File

@ -1,6 +1,6 @@
{ {
"name": "mjolnir", "name": "mjolnir",
"version": "1.2", "version": "1.2.1",
"description": "A moderation tool for Matrix", "description": "A moderation tool for Matrix",
"main": "lib/index.js", "main": "lib/index.js",
"repository": "git@github.com:matrix-org/mjolnir.git", "repository": "git@github.com:matrix-org/mjolnir.git",
@ -35,6 +35,7 @@
"config": "^3.3.6", "config": "^3.3.6",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.17", "express": "^4.17",
"html-to-text": "^8.0.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsdom": "^16.6.0", "jsdom": "^16.6.0",
"matrix-bot-sdk": "^0.5.19" "matrix-bot-sdk": "^0.5.19"

View File

@ -40,6 +40,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"
import { Healthz } from "./health/healthz"; import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue"; import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import * as htmlEscape from "escape-html"; import * as htmlEscape from "escape-html";
import { ReportManager } from "./report/ReportManager";
import { WebAPIs } from "./webapis/WebAPIs"; import { WebAPIs } from "./webapis/WebAPIs";
export const STATE_NOT_STARTED = "not_started"; export const STATE_NOT_STARTED = "not_started";
@ -221,7 +222,7 @@ export class Mjolnir {
// Setup Web APIs // Setup Web APIs
console.log("Creating Web APIs"); console.log("Creating Web APIs");
this.webapis = new WebAPIs(this.client); this.webapis = new WebAPIs(new ReportManager(this));
} }
public get lists(): BanList[] { public get lists(): BanList[] {

908
src/report/ReportManager.ts Normal file
View File

@ -0,0 +1,908 @@
/*
Copyright 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 { PowerLevelAction } from "matrix-bot-sdk/lib/models/PowerLevelAction";
import { LogService, UserID } from "matrix-bot-sdk";
import { htmlToText } from "html-to-text";
import { JSDOM } from 'jsdom';
import config from "../config";
import { Mjolnir } from "../Mjolnir";
/// Regexp, used to extract the action label from an action reaction
/// such as `⚽ Kick user @foobar:localhost from room [kick-user]`.
const REACTION_ACTION = /\[([a-z-]*)\]$/;
/// Regexp, used to extract the action label from a confirmation reaction
/// such as `🆗 ⚽ Kick user @foobar:localhost from room? [kick-user][confirm]`.
const REACTION_CONFIRMATION = /\[([a-z-]*)\]\[([a-z-]*)\]$/;
/// The hardcoded `confirm` string, as embedded in confirmation reactions.
const CONFIRM = "confirm";
/// The hardcoded `cancel` string, as embedded in confirmation reactions.
const CANCEL = "cancel";
/// Custom field embedded as part of notifications to embed abuse reports
/// (see `IReport` for the content).
export const ABUSE_REPORT_KEY = "org.matrix.mjolnir.abuse.report";
/// Custom field embedded as part of confirmation reactions to embed abuse
/// reports (see `IReportWithAction` for the content).
export const ABUSE_ACTION_CONFIRMATION_KEY = "org.matrix.mjolnir.abuse.action.confirmation";
const NATURE_DESCRIPTIONS_LIST: [string, string][] = [
["org.matrix.msc3215.abuse.nature.disagreement", "disagreement"],
["org.matrix.msc3215.abuse.nature.harassment", "harassment/bullying"],
["org.matrix.msc3215.abuse.nature.csam", "child sexual abuse material [likely illegal, consider warning authorities]"],
["org.matrix.msc3215.abuse.nature.hate_speech", "spam"],
["org.matrix.msc3215.abuse.nature.spam", "impersonation"],
["org.matrix.msc3215.abuse.nature.impersonation", "impersonation"],
["org.matrix.msc3215.abuse.nature.doxxing", "non-consensual sharing of identifiable private information of a third party (doxxing)"],
["org.matrix.msc3215.abuse.nature.violence", "threats of violence or death, either to self or others"],
["org.matrix.msc3215.abuse.nature.terrorism", "terrorism [likely illegal, consider warning authorities]"],
["org.matrix.msc3215.abuse.nature.unwanted_sexual_advances", "unwanted sexual advances, sextortion, ... [possibly illegal, consider warning authorities]"],
["org.matrix.msc3215.abuse.nature.ncii", "non consensual intimate imagery, including revenge porn"],
["org.matrix.msc3215.abuse.nature.nsfw", "NSFW content (pornography, gore...) in a SFW room"],
["org.matrix.msc3215.abuse.nature.disinformation", "disinformation"],
];
const NATURE_DESCRIPTIONS = new Map(NATURE_DESCRIPTIONS_LIST);
enum Kind {
//! A MSC3215-style moderation request
MODERATION_REQUEST,
//! An abuse report, as per https://matrix.org/docs/spec/client_server/r0.6.1#post-matrix-client-r0-rooms-roomid-report-eventid
SERVER_ABUSE_REPORT,
//! Mjölnir encountered a problem while attempting to handle a moderation request or abuse report
ERROR,
//! A moderation request or server abuse report escalated by the server/room moderators.
ESCALATED_REPORT,
}
/**
* A class designed to respond to abuse reports.
*/
export class ReportManager {
private displayManager: DisplayManager;
constructor(public mjolnir: Mjolnir) {
// Configure bot interactions.
mjolnir.client.on("room.event", async (roomId, event) => {
try {
switch (event["type"]) {
case "m.reaction": {
await this.handleReaction({ roomId, event });
break;
}
}
} catch (ex) {
LogService.error("ReportManager", "Uncaught error while handling an event", ex);
}
});
this.displayManager = new DisplayManager(this);
}
/**
* Display an incoming abuse report received, e.g. from the /report Matrix API.
*
* # Pre-requisites
*
* 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`;
* - 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 }) {
return this.displayManager.displayReportAndUI({ kind: Kind.SERVER_ABUSE_REPORT, event, reporterId, reason, moderationRoomId: config.managementRoom });
}
/**
* Handle a reaction to an abuse report.
*
* @param roomId The room in which the reaction took place.
* @param event The reaction.
*/
public async handleReaction({ roomId, event }: { roomId: string, event: any }) {
if (event.sender === await this.mjolnir.client.getUserId()) {
// Let's not react to our own reactions.
return;
}
if (roomId !== config.managementRoom) {
// Let's not accept commands in rooms other than the management room.
return;
}
let relation;
try {
relation = event["content"]["m.relates_to"]!;
} catch (ex) {
return;
}
// Get the original event.
let initialNoticeReport: IReport | undefined, confirmationReport: IReportWithAction | undefined;
try {
let originalEvent = await this.mjolnir.client.getEvent(roomId, relation.event_id);
if (!("content" in originalEvent)) {
return;
}
let content = originalEvent["content"];
if (ABUSE_REPORT_KEY in content) {
initialNoticeReport = content[ABUSE_REPORT_KEY]!;
} else if (ABUSE_ACTION_CONFIRMATION_KEY in content) {
confirmationReport = content[ABUSE_ACTION_CONFIRMATION_KEY]!;
}
} catch (ex) {
return;
}
if (!initialNoticeReport && !confirmationReport) {
return;
}
/*
At this point, we know that:
- We're in the management room;
- Either
- `initialNoticeReport` is defined and we're reacting to one of our reports; or
- `confirmationReport` is defined and we're reacting to a confirmation request.
*/
if (confirmationReport) {
// Extract the action and the decision.
let matches = relation.key.match(REACTION_CONFIRMATION);
if (!matches) {
// Invalid key.
return;
}
// Is it a yes or a no?
let decision;
switch (matches[2]) {
case CONFIRM:
decision = true;
break;
case CANCEL:
decision = false;
break;
default:
LogService.debug("ReportManager::handleReaction", "Unknown decision", matches[2]);
return;
}
if (decision) {
LogService.info("ReportManager::handleReaction", "User", event["sender"], "confirmed action", matches[1]);
await this.executeAction({
label: matches[1],
report: confirmationReport,
successEventId: confirmationReport.notification_event_id,
failureEventId: relation.event_id,
onSuccessRemoveEventId: relation.event_id,
moderationRoomId: roomId
})
} else {
LogService.info("ReportManager::handleReaction", "User", event["sender"], "cancelled action", matches[1]);
this.mjolnir.client.redactEvent(config.managementRoom, relation.event_id, "Action cancelled");
}
return;
} else if (initialNoticeReport) {
let matches = relation.key.match(REACTION_ACTION);
if (!matches) {
// Invalid key.
return;
}
let label: string = matches[1]!;
let action: IUIAction | undefined = ACTIONS.get(label);
if (!action) {
return;
}
confirmationReport = {
action: label,
notification_event_id: relation.event_id,
...initialNoticeReport
};
LogService.info("ReportManager::handleReaction", "User", event["sender"], "picked action", label, initialNoticeReport);
if (action.needsConfirmation) {
// Send a confirmation request.
let confirmation = {
msgtype: "m.notice",
body: `${action.emoji} ${await action.title(this, initialNoticeReport)}?`,
"m.relationship": {
"rel_type": "m.reference",
"event_id": relation.event_id,
}
};
confirmation[ABUSE_ACTION_CONFIRMATION_KEY] = confirmationReport;
let requestConfirmationEventId = await this.mjolnir.client.sendMessage(config.managementRoom, confirmation);
await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": requestConfirmationEventId,
"key": `🆗 ${action.emoji} ${await action.title(this, initialNoticeReport)} [${action.label}][${CONFIRM}]`
}
});
await this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": requestConfirmationEventId,
"key": `⬛ Cancel [${action.label}][${CANCEL}]`
}
});
} else {
// Execute immediately.
LogService.info("ReportManager::handleReaction", "User", event["sender"], "executed (no confirmation needed) action", matches[1]);
this.executeAction({
label,
report: confirmationReport,
successEventId: relation.event_id,
failureEventId: relation.eventId,
moderationRoomId: roomId
})
}
}
}
/**
* Execute a report-specific action.
*
* This is executed when the user clicks on an action to execute (if the action
* does not need confirmation) or when the user clicks on "confirm" in a confirmation
* (otherwise).
*
* @param label The type of action to execute, e.g. `kick-user`.
* @param report The abuse report on which to take action.
* @param successEventId The event to annotate with a "OK" in case of success.
* @param failureEventId The event to annotate with a "FAIL" in case of failure.
* @param onSuccessRemoveEventId Optionally, an event to remove in case of success (e.g. the confirmation dialog).
*/
private async executeAction({ label, report, successEventId, failureEventId, onSuccessRemoveEventId, moderationRoomId }: { label: string, report: IReportWithAction, successEventId: string, failureEventId: string, onSuccessRemoveEventId?: string, moderationRoomId: string }) {
let action: IUIAction | undefined = ACTIONS.get(label);
if (!action) {
return;
}
let error: any = null;
let response;
try {
// Check security.
if (moderationRoomId === config.managementRoom) {
// Always accept actions executed from the management room.
} else {
throw new Error("Security error: Cannot execute this action.");
}
response = await action.execute(this, report, moderationRoomId, this.displayManager);
} catch (ex) {
error = ex;
}
if (error) {
this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": failureEventId,
"key": `${action.emoji}`
}
});
this.mjolnir.client.sendEvent(config.managementRoom, "m.notice", {
"body": error.message || "<unknown error>",
"m.relationship": {
"rel_type": "m.reference",
"event_id": failureEventId,
}
})
} else {
this.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": successEventId,
"key": `${action.emoji}`
}
});
if (onSuccessRemoveEventId) {
this.mjolnir.client.redactEvent(config.managementRoom, onSuccessRemoveEventId, "Action complete");
}
if (response) {
this.mjolnir.client.sendMessage(config.managementRoom, {
msgtype: "m.notice",
"formatted_body": response,
format: "org.matrix.custom.html",
"body": htmlToText(response),
"m.relationship": {
"rel_type": "m.reference",
"event_id": successEventId
}
})
}
}
}
}
/**
* An abuse report received from a user.
*
* Note: These reports end up embedded in Matrix messages, behind key `ABUSE_REPORT_KEY`,
* so we're using Matrix naming conventions rather than JS/TS naming conventions.
*/
interface IReport {
/**
* The user who sent the abuse report.
*/
readonly accused_id: string,
/**
* The user who sent the message reported as abuse.
*/
readonly reporter_id: string,
/**
* The room in which `eventId` took place.
*/
readonly room_id: string,
readonly room_alias_or_id: string,
/**
* The event reported as abuse.
*/
readonly event_id: string,
}
/**
* An abuse report, extended with the information we need for a confirmation report.
*
* Note: These reports end up embedded in Matrix messages, behind key `ABUSE_ACTION_CONFIRMATION_KEY`,
* so we're using Matrix naming conventions rather than JS/TS naming conventions.
*/
interface IReportWithAction extends IReport {
/**
* The label of the action we're confirming, e.g. `kick-user`.
*/
readonly action: string,
/**
* The event in which we originally notified of the abuse.
*/
readonly notification_event_id: string,
}
/**
* A user action displayed in the UI as a Matrix reaction.
*/
interface IUIAction {
/**
* A unique label.
*
* Used by Mjölnir to differentiate the actions, e.g. `kick-user`.
*/
readonly label: string;
/**
* A unique Emoji.
*
* Used to help users avoid making errors when clicking on a button.
*/
readonly emoji: string;
/**
* If `true`, this is an action that needs confirmation. Otherwise, the
* action may be executed immediately.
*/
readonly needsConfirmation: boolean;
/**
* Detect whether the action may be executed, e.g. whether Mjölnir has
* sufficient powerlevel to execute this action.
*
* **Security caveat** This assumes that the security policy on whether
* the operation can be executed is:
*
* > *Anyone* in the moderation room and who isn't muted can execute
* > an operation iff Mjölnir has the rights to execute it.
*
* @param report Details on the abuse report.
*/
canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise<boolean>;
/**
* A human-readable title to display for the end-user.
*
* @param report Details on the abuse report.
*/
title(manager: ReportManager, report: IReport): Promise<string>;
/**
* Attempt to execute the action.
*/
execute(manager: ReportManager, report: IReport, moderationRoomId: string, displayManager: DisplayManager): Promise<string | undefined>;
}
/**
* UI action: Ignore bad report
*/
class IgnoreBadReport implements IUIAction {
public label = "bad-report";
public emoji = "🚯";
public needsConfirmation = true;
public async canExecute(_manager: ReportManager, _report: IReport): Promise<boolean> {
return true;
}
public async title(_manager: ReportManager, _report: IReport): Promise<string> {
return "Ignore bad report";
}
public async execute(manager: ReportManager, report: IReportWithAction): Promise<string | undefined> {
await manager.mjolnir.client.sendEvent(config.managementRoom, "m.room.message",
{
msgtype: "m.notice",
body: "Report classified as invalid",
"m.new_content": {
"body": `Report by user ${report.reporter_id} has been classified as invalid`,
"msgtype": "m.text"
},
"m.relates_to": {
"rel_type": "m.replace",
"event_id": report.notification_event_id
}
}
);
return;
}
}
/**
* UI action: Redact reported message.
*/
class RedactMessage implements IUIAction {
public label = "redact-message";
public emoji = "🗍";
public needsConfirmation = true;
public async canExecute(manager: ReportManager, report: IReport): Promise<boolean> {
try {
return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.RedactEvents);
} catch (ex) {
return false;
}
}
public async title(_manager: ReportManager, report: IReport): Promise<string> {
return `Redact event ${report.event_id}`;
}
public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string): Promise<string | undefined> {
await manager.mjolnir.client.redactEvent(report.room_id, report.event_id);
return;
}
}
/**
* UI action: Kick accused user.
*/
class KickAccused implements IUIAction {
public label = "kick-accused";
public emoji = "⚽";
public needsConfirmation = true;
public async canExecute(manager: ReportManager, report: IReport): Promise<boolean> {
try {
return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Kick);
} catch (ex) {
return false;
}
}
public async title(_manager: ReportManager, report: IReport): Promise<string> {
return `Kick ${report.accused_id} from room ${report.room_alias_or_id}`;
}
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
await manager.mjolnir.client.kickUser(report.accused_id, report.room_id);
return;
}
}
/**
* UI action: Mute accused user.
*/
class MuteAccused implements IUIAction {
public label = "mute-accused";
public emoji = "🤐";
public needsConfirmation = true;
public async canExecute(manager: ReportManager, report: IReport): Promise<boolean> {
try {
return await manager.mjolnir.client.userHasPowerLevelFor(await manager.mjolnir.client.getUserId(), report.room_id, "m.room.power_levels", true);
} catch (ex) {
return false;
}
}
public async title(_manager: ReportManager, report: IReport): Promise<string> {
return `Mute ${report.accused_id} in room ${report.room_alias_or_id}`;
}
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
await manager.mjolnir.client.setUserPowerLevel(report.accused_id, report.room_id, -1);
return;
}
}
/**
* UI action: Ban accused.
*/
class BanAccused implements IUIAction {
public label = "ban-accused";
public emoji = "🚫";
public needsConfirmation = true;
public async canExecute(manager: ReportManager, report: IReport): Promise<boolean> {
try {
return await manager.mjolnir.client.userHasPowerLevelForAction(await manager.mjolnir.client.getUserId(), report.room_id, PowerLevelAction.Ban);
} catch (ex) {
return false;
}
}
public async title(_manager: ReportManager, report: IReport): Promise<string> {
return `Ban ${report.accused_id} from room ${report.room_alias_or_id}`;
}
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
await manager.mjolnir.client.banUser(report.accused_id, report.room_id);
return;
}
}
/**
* UI action: Help.
*/
class Help implements IUIAction {
public label = "help";
public emoji = "❓";
public needsConfirmation = false;
public async canExecute(_manager: ReportManager, _report: IReport): Promise<boolean> {
return true;
}
public async title(_manager: ReportManager, _report: IReport): Promise<string> {
return "Help";
}
public async execute(manager: ReportManager, report: IReport): Promise<string | undefined> {
// Produce a html list of actions, in the order specified by ACTION_LIST.
let list: string[] = [];
for (let action of ACTION_LIST) {
list.push(`<li>${action.emoji} ${await action.title(manager, report)}</li>`);
}
let body = `<ul>${list.join("\n")}</ul>`;
return body;
}
}
/**
* Escalate to the moderation room of this instance of Mjölnir.
*/
class EscalateToServerModerationRoom implements IUIAction {
public label = "escalate-to-server-moderation";
public emoji = "⏫";
public needsConfirmation = true;
public async canExecute(manager: ReportManager, report: IReport, moderationRoomId: string): Promise<boolean> {
if (moderationRoomId === config.managementRoom) {
// We're already at the top of the chain.
return false;
}
try {
await manager.mjolnir.client.getEvent(report.room_id, report.event_id);
} catch (ex) {
// We can't fetch the event.
return false;
}
return true;
}
public async title(manager: ReportManager, _report: IReport): Promise<string> {
return `Escalate report to ${getHomeserver(await manager.mjolnir.client.getUserId())} server moderators`;
}
public async execute(manager: ReportManager, report: IReport, _moderationRoomId: string, displayManager: DisplayManager): Promise<string | undefined> {
let event = await manager.mjolnir.client.getEvent(report.room_id, report.event_id);
// Display the report and UI directly in the management room, as if it had been
// received from /report.
//
// Security:
// - `kind`: statically known good;
// - `moderationRoomId`: statically known good;
// - `reporterId`: we trust `report`, could be forged by a moderator, low impact;
// - `event`: checked just before.
await displayManager.displayReportAndUI({ kind: Kind.ESCALATED_REPORT, reporterId: report.reporter_id, moderationRoomId: config.managementRoom, event });
return;
}
}
class DisplayManager {
constructor(private owner: ReportManager) {
}
/**
* Display the report and any UI button.
*
*
* # Security
*
* This method DOES NOT PERFORM ANY SECURITY CHECKS.
*
* @param kind The kind of report (server-wide abuse report / room moderation request). Low security.
* @param event The offending event. The fact that it's the offending event MUST be checked. No assumptions are made on the content.
* @param reporterId The user who reported the event. MUST be checked.
* @param reason A user-provided comment. Low-security.
* @param moderationRoomId The room in which the report and ui will be displayed. MUST be checked.
*/
public async displayReportAndUI(args: { kind: Kind, event: any, reporterId: string, reason?: string, nature?: string, moderationRoomId: string, error?: string }) {
let { kind, event, reporterId, reason, nature, moderationRoomId, error } = args;
let roomId = event["room_id"]!;
let eventId = event["event_id"]!;
let roomAliasOrId = roomId;
try {
roomAliasOrId = await this.owner.mjolnir.client.getPublishedAlias(roomId) || roomId;
} catch (ex) {
// Ignore.
}
let eventContent;
try {
if (event["type"] === "m.room.encrypted") {
eventContent = { msg: "<encrypted content>" };
} else if ("content" in event) {
const MAX_EVENT_CONTENT_LENGTH = 2048;
const MAX_NEWLINES = 64;
if ("formatted_body" in event.content) {
eventContent = { html: this.limitLength(event.content.formatted_body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) };
} else if ("body" in event.content) {
eventContent = { text: this.limitLength(event.content.body, MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) };
} else {
eventContent = { text: this.limitLength(JSON.stringify(event["content"], null, 2), MAX_EVENT_CONTENT_LENGTH, MAX_NEWLINES) };
}
}
} catch (ex) {
eventContent = { msg: `<Cannot extract event. Please verify that Mjölnir has been invited to room ${roomAliasOrId} and made room moderator or administrator>.` };
}
let accusedId = event["sender"];
let reporterDisplayName: string, accusedDisplayName: string;
try {
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;
} catch (ex) {
accusedDisplayName = "<Error: Cannot extract accused display name>";
}
let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`;
let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrId)}`;
let eventTimestamp;
try {
eventTimestamp = new Date(event["origin_server_ts"]).toUTCString();
} catch (ex) {
eventTimestamp = `<Cannot extract event. Please verify that Mjölnir has been invited to room ${roomAliasOrId} and made room moderator or administrator>.`;
}
let title;
switch (kind) {
case Kind.MODERATION_REQUEST:
title = "Moderation request";
break;
case Kind.SERVER_ABUSE_REPORT:
title = "Abuse report";
break;
case Kind.ESCALATED_REPORT:
title = "Moderation request escalated by moderators";
break;
case Kind.ERROR:
title = "Error";
break;
}
let readableNature = "unspecified";
if (nature) {
readableNature = NATURE_DESCRIPTIONS.get(nature) || readableNature;
}
// We need to send the report as html to be able to use spoiler markings.
// We build this as dom to be absolutely certain that we're not introducing
// any kind of injection within the report.
// Please do NOT insert any `${}` in the following backticks, to avoid
// any XSS attack.
const document = new JSDOM(`
<body>
<div>
<b><span id="title"></span></b>
</div>
<div>
<b>Filed by</b> <span id='reporter-display-name'></span> (<code id='reporter-id'></code>)
</div>
<b>Against</b> <span id='accused-display-name'></span> (<code id='accused-id'></code>)
<div>
<b>Nature</b> <span id='nature-display'></span> (<code id='nature-source'></code>)
</div>
<div>
<b>Room</b> <a id='room-shortcut'><span id='room-alias-or-id'></span></a>
</div>
<hr />
<div id='details-or-error'>
<details>
<summary>Event details</summary>
<div>
<b>Event</b> <span id='event-id'></span> <a id='event-shortcut'>Go to event</a>
</div>
<div>
<b>When</b> <span id='event-timestamp'></span>
</div>
<div>
<b>Content</b> <span id='event-container'><code id='event-content'></code><span>
</div>
</details>
</div>
<hr />
<details>
<summary>Comments</summary>
<b>Comments</b> <code id='reason-content'></code></div>
</details>
</body>`).window.document;
// ...insert text content
for (let [key, value] of [
['title', title],
['reporter-display-name', reporterDisplayName],
['reporter-id', reporterId],
['accused-display-name', accusedDisplayName],
['accused-id', accusedId],
['event-id', eventId],
['room-alias-or-id', roomAliasOrId],
['reason-content', reason || "<no reason given>"],
['nature-display', readableNature],
['nature-source', nature || "<no nature provided>"],
['event-timestamp', eventTimestamp],
['details-or-error', kind === Kind.ERROR ? error : null]
]) {
let node = document.getElementById(key);
if (node && value) {
node.textContent = value;
}
}
// ...insert links
for (let [key, value] of [
['event-shortcut', eventShortcut],
['room-shortcut', roomShortcut],
]) {
let node = document.getElementById(key) as HTMLAnchorElement;
if (node) {
node.href = value;
}
}
// ...insert HTML content
for (let [key, value] of [
['event-content', eventContent],
]) {
let node = document.getElementById(key);
if (node) {
if ("msg" in value) {
node.textContent = value.msg;
} else if ("text" in value) {
node.textContent = value.text;
} else if ("html" in value) {
node.innerHTML = value.html;
}
}
}
// ...set presentation
if (!("msg" in eventContent)) {
// If there's some event content, mark it as a spoiler.
document.getElementById('event-container')!.
setAttribute("data-mx-spoiler", "");
}
// Embed additional information in the notice, for use by the
// action buttons.
let report: IReport = {
accused_id: accusedId,
reporter_id: reporterId,
event_id: eventId,
room_id: roomId,
room_alias_or_id: roomAliasOrId,
};
let notice = {
msgtype: "m.notice",
body: htmlToText(document.body.outerHTML, { wordwrap: false }),
format: "org.matrix.custom.html",
formatted_body: document.body.outerHTML,
};
notice[ABUSE_REPORT_KEY] = report;
let noticeEventId = await this.owner.mjolnir.client.sendMessage(config.managementRoom, notice);
if (kind !== Kind.ERROR) {
// Now let's display buttons.
for (let [label, action] of ACTIONS) {
// Display buttons for actions that can be executed.
if (!await action.canExecute(this.owner, report, moderationRoomId)) {
continue;
}
await this.owner.mjolnir.client.sendEvent(config.managementRoom, "m.reaction", {
"m.relates_to": {
"rel_type": "m.annotation",
"event_id": noticeEventId,
"key": `${action.emoji} ${await action.title(this.owner, report)} [${label}]`
}
});
}
}
}
private limitLength(text: string, maxLength: number, maxNewlines: number): string {
let originalLength = text.length
// Shorten text if it is too long.
if (text.length > maxLength) {
text = text.substring(0, maxLength);
}
// Shorten text if there are too many newlines.
// Note: This only looks for text newlines, not `<div>`, `<li>` or any other HTML box.
let index = -1;
let newLines = 0;
while (true) {
index = text.indexOf("\n", index);
if (index === -1) {
break;
}
index += 1;
newLines += 1;
if (newLines > maxNewlines) {
text = text.substring(0, index);
break;
}
};
if (text.length < originalLength) {
return `${text}... [total: ${originalLength} characters]`;
} else {
return text;
}
}
}
/**
* The actions we may be able to undertake in reaction to a report.
*
* As a list, ordered for displayed when users click on "Help".
*/
const ACTION_LIST = [
new KickAccused(),
new RedactMessage(),
new MuteAccused(),
new BanAccused(),
new EscalateToServerModerationRoom(),
new IgnoreBadReport(),
new Help()
];
/**
* The actions we may be able to undertake in reaction to a report.
*
* As a map of labels => actions.
*/
const ACTIONS = new Map(ACTION_LIST.map(action => [action.label, action]));
function getHomeserver(userId: string): string {
return new UserID(userId).domain
}

View File

@ -17,11 +17,10 @@ limitations under the License.
import { Server } from "http"; import { Server } from "http";
import * as express from "express"; import * as express from "express";
import { JSDOM } from 'jsdom';
import { MatrixClient } from "matrix-bot-sdk"; import { MatrixClient } from "matrix-bot-sdk";
import config from "../config"; import config from "../config";
import { ReportManager } from "../report/ReportManager";
/** /**
* A common prefix for all web-exposed APIs. * A common prefix for all web-exposed APIs.
@ -34,7 +33,7 @@ export class WebAPIs {
private webController: express.Express = express(); private webController: express.Express = express();
private httpServer?: Server; private httpServer?: Server;
constructor(private client: MatrixClient) { constructor(private reportManager: ReportManager) {
// Setup JSON parsing. // Setup JSON parsing.
this.webController.use(express.json()); this.webController.use(express.json());
} }
@ -153,91 +152,15 @@ export class WebAPIs {
// with all Matrix homeservers, rather than just Synapse. // with all Matrix homeservers, rather than just Synapse.
event = await reporterClient.getEvent(roomId, eventId); event = await reporterClient.getEvent(roomId, eventId);
} }
let accusedId: string = event["sender"];
/*
Past this point, the following invariants hold:
- The reporter is a member of `roomId`.
- Event `eventId` did take place in room `roomId`.
- The reporter could witness event `eventId` in room `roomId`.
- Event `eventId` was reported by user `accusedId`.
*/
let { displayname: reporterDisplayName }: { displayname: string } = await this.client.getUserProfile(reporterId);
let { displayname: accusedDisplayName }: { displayname: string } = await this.client.getUserProfile(accusedId);
let roomAliasOrID = roomId;
try {
roomAliasOrID = await this.client.getPublishedAlias(roomId);
} catch (ex) {
// Ignore.
}
let eventShortcut = `https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(eventId)}`;
let roomShortcut = `https://matrix.to/#/${encodeURIComponent(roomAliasOrID)}`;
let eventContent;
if (event["type"] === "m.room.encrypted") {
eventContent = "<encrypted content>";
} else {
eventContent = JSON.stringify(event["content"], null, 2);
}
let reason = request.body["reason"]; let reason = request.body["reason"];
await this.reportManager.handleServerAbuseReport({ roomId, eventId, reporterId, event, reason });
// We now have all the information we need to produce an abuse report.
// We need to send the report as html to be able to use spoiler markings.
// We build this as dom to be absolutely certain that we're not introducing
// any kind of injection within the report.
const document = new JSDOM(
"<body>" +
"User <code id='reporter-display-name'></code> (<code id='reporter-id'></code>) " +
"reported <a id='event-shortcut'>event <span id='event-id'></span></a> " +
"sent by user <b><span id='accused-display-name'></span> (<span id='accused-id'></span>)</b> " +
"in <a id='room-shortcut'>room <span id='room-alias-or-id'></span></a>." +
"<div>Event content <span id='event-container'><code id='event-content'></code><span></div>" +
"<div>Reporter commented: <code id='reason-content'></code></div>" +
"</body>")
.window
.document;
// ...insert text content
for (let [key, value] of [
['reporter-display-name', reporterDisplayName],
['reporter-id', reporterId],
['accused-display-name', accusedDisplayName],
['accused-id', accusedId],
['event-id', eventId],
['room-alias-or-id', roomAliasOrID],
['event-content', eventContent],
['reason-content', reason || "<no reason given>"]
]) {
document.getElementById(key)!.textContent = value;
}
// ...insert attributes
for (let [key, value] of [
['event-shortcut', eventShortcut],
['room-shortcut', roomShortcut],
]) {
(document.getElementById(key)! as HTMLAnchorElement).href = value;
}
// ...set presentation
if (event["type"] !== "m.room.encrypted") {
// If there's some event content, mark it as a spoiler.
document.getElementById('event-container')!.
setAttribute("data-mx-spoiler", "");
}
// Possible evolutions: in future versions, we could add the ability to one-click discard, kick, ban.
// Send the report and we're done!
// We MUST send this report with the regular Mjölnir client.
await this.client.sendHtmlNotice(config.managementRoom, document.body.outerHTML);
console.debug("Formatted abuse report sent");
// Match the spec behavior of `/report`: return 200 and an empty JSON. // Match the spec behavior of `/report`: return 200 and an empty JSON.
response.status(200).json({}); response.status(200).json({});
} catch (ex) { } catch (ex) {
console.warn("Error responding to an abuse report", roomId, eventId, ex); console.warn("Error responding to an abuse report", roomId, eventId, ex);
response.status(503);
} }
} }
} }

View File

@ -3,11 +3,22 @@ import { strict as assert } from "assert";
import config from "../../src/config"; import config from "../../src/config";
import { matrixClient, mjolnir } from "./mjolnirSetupUtils"; import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
import { newTestUser } from "./clientHelper"; import { newTestUser } from "./clientHelper";
import { ReportManager, ABUSE_ACTION_CONFIRMATION_KEY, ABUSE_REPORT_KEY } from "../../src/report/ReportManager";
/** /**
* Test the ability to turn abuse reports into room messages. * Test the ability to turn abuse reports into room messages.
*/ */
const REPORT_NOTICE_REGEXPS = {
reporter: /Filed by (?<reporterDisplay>[^ ]*) \((?<reporterId>[^ ]*)\)/,
accused: /Against (?<accusedDisplay>[^ ]*) \((?<accusedId>[^ ]*)\)/,
room: /Room (?<roomAliasOrId>[^ ]*)/,
event: /Event (?<eventId>[^ ]*) Go to event/,
content: /Content (?<eventContent>.*)/,
comments: /Comments Comments (?<comments>.*)/
};
describe("Test: Reporting abuse", async () => { describe("Test: Reporting abuse", async () => {
it('Mjölnir intercepts abuse reports', async function() { it('Mjölnir intercepts abuse reports', async function() {
this.timeout(10000); this.timeout(10000);
@ -35,9 +46,15 @@ describe("Test: Reporting abuse", async () => {
let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported. let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported.
let badText = `BAD: ${Math.random()}`; // Will be reported as abuse. let badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse. let badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse.
let badText3 = `<b>BAD</b>: ${Math.random()}`; // Will be reported as abuse.
let badText4 = [...Array(1024)].map(_ => `${Math.random()}`).join(""); // Text is too long.
let badText5 = [...Array(1024)].map(_ => "ABC").join("\n"); // Text has too many lines.
let goodEventId = await goodUser.sendText(roomId, goodText); let goodEventId = await goodUser.sendText(roomId, goodText);
let badEventId = await badUser.sendText(roomId, badText); let badEventId = await badUser.sendText(roomId, badText);
let badEventId2 = await badUser.sendText(roomId, badText2); let badEventId2 = await badUser.sendText(roomId, badText2);
let badEventId3 = await badUser.sendText(roomId, badText3);
let badEventId4 = await badUser.sendText(roomId, badText4);
let badEventId5 = await badUser.sendText(roomId, badText5);
let badEvent2Comment = `COMMENT: ${Math.random()}`; let badEvent2Comment = `COMMENT: ${Math.random()}`;
console.log("Test: Reporting abuse - send reports"); console.log("Test: Reporting abuse - send reports");
@ -73,39 +90,264 @@ describe("Test: Reporting abuse", async () => {
console.error("Could not send second report", e.body || e); console.error("Could not send second report", e.body || e);
throw e; throw e;
} }
// FIXME: Also test with embedded HTML.
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId3)}`, "");
reportsToFind.push({
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId3,
text: badText3,
comment: null,
});
} catch (e) {
console.error("Could not send third report", e.body || e);
throw e;
}
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId4)}`, "");
reportsToFind.push({
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId4,
text: null,
textPrefix: badText4.substring(0, 256),
comment: null,
});
} catch (e) {
console.error("Could not send fourth report", e.body || e);
throw e;
}
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId5)}`, "");
reportsToFind.push({
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId5,
text: null,
textPrefix: badText5.substring(0, 256).split("\n").join(" "),
comment: null,
});
} catch (e) {
console.error("Could not send fifth report", e.body || e);
throw e;
}
console.log("Test: Reporting abuse - wait"); console.log("Test: Reporting abuse - wait");
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
let found = []; let found = [];
let regexp = /^User ([^ ]*) \(([^ ]*)\) reported event ([^ ]*).*sent by user ([^ ]*) \(([^ ]*)\).*\n.*\nReporter commented: (.*)/m;
for (let toFind of reportsToFind) { for (let toFind of reportsToFind) {
for (let event of notices) { for (let event of notices) {
if ("content" in event && "body" in event.content) { if ("content" in event && "body" in event.content) {
if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != toFind.eventId) {
// Not a report or not our report.
continue;
}
let report = event.content[ABUSE_REPORT_KEY];
let body = event.content.body as string; let body = event.content.body as string;
let match = body.match(regexp); let matches = new Map();
if (!match) { for (let key of Object.keys(REPORT_NOTICE_REGEXPS)) {
let match = body.match(REPORT_NOTICE_REGEXPS[key]);
if (match) {
console.debug("We have a match", key, REPORT_NOTICE_REGEXPS[key], match.groups);
} else {
console.debug("Not a match", key, REPORT_NOTICE_REGEXPS[key]);
// Not a report, skipping.
matches = null;
break;
}
matches.set(key, match);
}
if (!matches) {
// Not a report, skipping. // Not a report, skipping.
continue; continue;
} }
let [, reporterDisplay, reporterId, eventId, accusedDisplay, accusedId, reason] = match;
if (eventId != toFind.eventId) { assert(body.length < 3000, `The report shouldn't be too long ${body.length}`);
// Different event id, skipping. assert(body.split("\n").length < 200, "The report shouldn't have too many newlines.");
continue;
assert.equal(matches.get("event")!.groups.eventId, toFind.eventId, "The report should specify the correct event id");;
assert.equal(matches.get("reporter")!.groups.reporterId, toFind.reporterId, "The report should specify the correct reporter");
assert.equal(report.reporter_id, toFind.reporterId, "The embedded report should specify the correct reporter");
assert.ok(toFind.reporterId.includes(matches.get("reporter")!.groups.reporterDisplay), "The report should display the correct reporter");
assert.equal(matches.get("accused")!.groups.accusedId, toFind.accusedId, "The report should specify the correct accused");
assert.equal(report.accused_id, toFind.accusedId, "The embedded report should specify the correct accused");
assert.ok(toFind.accusedId.includes(matches.get("accused")!.groups.accusedDisplay), "The report should display the correct reporter");
if (toFind.text) {
assert.equal(matches.get("content")!.groups.eventContent, toFind.text, "The report should contain the text we inserted in the event");
}
if (toFind.textPrefix) {
assert.ok(matches.get("content")!.groups.eventContent.startsWith(toFind.textPrefix), `The report should contain a prefix of the long text we inserted in the event: ${toFind.textPrefix} in? ${matches.get("content")!.groups.eventContent}`);
} }
assert.equal(reporterId, toFind.reporterId, "The report should specify the correct reporter");
assert.ok(toFind.reporterId.includes(reporterDisplay), "The report should display the correct reporter");
assert.equal(accusedId, toFind.accusedId, "The report should specify the correct accused");
assert.ok(toFind.accusedId.includes(accusedDisplay), "The report should display the correct reporter");
assert.ok(body.includes(toFind.text), "The report should contain the text we inserted in the event");
if (toFind.comment) { if (toFind.comment) {
assert.equal(reason, toFind.comment, "The report should contain the comment we added"); assert.equal(matches.get("comments")!.groups.comments, toFind.comment, "The report should contain the comment we added");
} }
assert.equal(matches.get("room")!.groups.roomAliasOrId, roomId, "The report should specify the correct room");
assert.equal(report.room_id, roomId, "The embedded report should specify the correct room");
found.push(toFind); found.push(toFind);
break; break;
} }
} }
} }
assert.deepEqual(reportsToFind, found); assert.deepEqual(found, reportsToFind);
})
// Since Mjölnir is not a member of the room, the only buttons we should find
// are `help` and `ignore`.
for (let event of notices) {
if (event.content && event.content["m.relates_to"] && event.content["m.relates_to"]["key"]) {
let regexp = /\/([[^]]*)\]/;
let matches = event.content["m.relates_to"]["key"].match(regexp);
if (!matches) {
continue;
}
switch (matches[1]) {
case "bad-report":
case "help":
continue;
default:
throw new Error(`Didn't expect label ${matches[1]}`);
}
}
}
});
it('The redact action works', async function() {
this.timeout(10000);
// Listen for any notices that show up.
let notices = [];
matrixClient().on("room.event", (roomId, event) => {
if (roomId = config.managementRoom) {
notices.push(event);
}
});
// Create a moderator.
let moderatorUser = await newTestUser(false, "reacting-abuse-moderator-user");
matrixClient().inviteUser(await moderatorUser.getUserId(), config.managementRoom);
await moderatorUser.joinRoom(config.managementRoom);
// 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 goodUserId = await goodUser.getUserId();
let badUserId = await badUser.getUserId();
let roomId = await moderatorUser.createRoom({ invite: [await badUser.getUserId()] });
await moderatorUser.inviteUser(await goodUser.getUserId(), roomId);
await moderatorUser.inviteUser(await badUser.getUserId(), roomId);
await badUser.joinRoom(roomId);
await goodUser.joinRoom(roomId);
// Setup Mjölnir as moderator for our room.
await moderatorUser.inviteUser(await matrixClient().getUserId(), roomId);
await moderatorUser.setUserPowerLevel(await matrixClient().getUserId(), roomId, 100);
console.log("Test: Reporting abuse - send messages");
// Exchange a few messages.
let goodText = `GOOD: ${Math.random()}`; // Will NOT be reported.
let badText = `BAD: ${Math.random()}`; // Will be reported as abuse.
let goodEventId = await goodUser.sendText(roomId, goodText);
let badEventId = await badUser.sendText(roomId, badText);
let goodEventId2 = await goodUser.sendText(roomId, goodText);
console.log("Test: Reporting abuse - send reports");
// Time to report.
let reportToFind = {
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId,
text: badText,
comment: null,
};
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`);
} catch (e) {
console.error("Could not send first report", e.body || e);
throw e;
}
console.log("Test: Reporting abuse - wait");
await new Promise(resolve => setTimeout(resolve, 1000));
let mjolnirRooms = new Set(await matrixClient().getJoinedRooms());
assert.ok(mjolnirRooms.has(roomId), "Mjölnir should be a member of the room");
// Find the notice
let noticeId;
for (let event of notices) {
if ("content" in event && ABUSE_REPORT_KEY in event.content) {
if (!(ABUSE_REPORT_KEY in event.content) || event.content[ABUSE_REPORT_KEY].event_id != badEventId) {
// Not a report or not our report.
continue;
}
noticeId = event.event_id;
break;
}
}
assert.ok(noticeId, "We should have found our notice");
// Find the buttons.
let buttons = [];
for (let event of notices) {
if (event["type"] != "m.reaction") {
continue;
}
if (event["content"]["m.relates_to"]["rel_type"] != "m.annotation") {
continue;
}
if (event["content"]["m.relates_to"]["event_id"] != noticeId) {
continue;
}
buttons.push(event);
}
// Find the redact button... and click it.
let redactButtonId = null;
for (let button of buttons) {
if (button["content"]["m.relates_to"]["key"].includes("[redact-message]")) {
redactButtonId = button["event_id"];
await moderatorUser.sendEvent(config.managementRoom, "m.reaction", button["content"]);
break;
}
}
assert.ok(redactButtonId, "We should have found the redact button");
await new Promise(resolve => setTimeout(resolve, 1000));
// This should have triggered a confirmation request, with more buttons!
let confirmEventId = null;
for (let event of notices) {
console.debug("Is this the confirm button?", event);
if (!event["content"]["m.relates_to"]) {
console.debug("Not a reaction");
continue;
}
if (!event["content"]["m.relates_to"]["key"].includes("[confirm]")) {
console.debug("Not confirm");
continue;
}
if (!event["content"]["m.relates_to"]["event_id"] == redactButtonId) {
console.debug("Not reaction to redact button");
continue;
}
// It's the confirm button, click it!
confirmEventId = event["event_id"];
await moderatorUser.sendEvent(config.managementRoom, "m.reaction", event["content"]);
break;
}
assert.ok(confirmEventId, "We should have found the confirm button");
await new Promise(resolve => setTimeout(resolve, 1000));
// This should have redacted the message.
let newBadEvent = await matrixClient().getEvent(roomId, badEventId);
assert.deepEqual(Object.keys(newBadEvent.content), [], "Redaction should have removed the content of the offending event");
});
}); });

View File

@ -7,7 +7,7 @@ import config from "../../src/config";
* Register a user using the synapse admin api that requires the use of a registration secret rather than an admin user. * Register a user using the synapse admin api that requires the use of a registration secret rather than an admin user.
* This should only be used by test code and should not be included from any file in the source directory * This should only be used by test code and should not be included from any file in the source directory
* either by explicit imports or copy pasting. * either by explicit imports or copy pasting.
* *
* @param username The username to give the user. * @param username The username to give the user.
* @param displayname The displayname to give the user. * @param displayname The displayname to give the user.
* @param password The password to use. * @param password The password to use.
@ -55,7 +55,7 @@ export async function registerNewTestUser(isAdmin: boolean, label: string = "")
}) })
} while (!isUserValid); } while (!isUserValid);
return username; return username;
} }
/** /**
* Registers a unique test user and returns a `MatrixClient` logged in and ready to use. * Registers a unique test user and returns a `MatrixClient` logged in and ready to use.
@ -81,5 +81,5 @@ export function noticeListener(targetRoomdId: string, cb) {
if (roomId !== targetRoomdId) return; if (roomId !== targetRoomdId) return;
if (event?.content?.msgtype !== "m.notice") return; if (event?.content?.msgtype !== "m.notice") return;
cb(event); cb(event);
} }
} }

View File

@ -6,5 +6,5 @@ import { makeMjolnir } from "./mjolnirSetupUtils";
(async () => { (async () => {
let mjolnir = await makeMjolnir(); let mjolnir = await makeMjolnir();
await mjolnir.start() await mjolnir.start();
})(); })();

View File

@ -36,7 +36,9 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
.catch(async e => { .catch(async e => {
if (e?.body?.errcode === 'M_NOT_FOUND') { if (e?.body?.errcode === 'M_NOT_FOUND') {
console.info(`${alias} hasn't been created yet, so we're making it now.`) console.info(`${alias} hasn't been created yet, so we're making it now.`)
let roomId = await client.createRoom(); let roomId = await client.createRoom({
visibility: "public",
});
await client.createRoomAlias(config.managementRoom, roomId); await client.createRoomAlias(config.managementRoom, roomId);
return roomId return roomId
} }
@ -49,11 +51,11 @@ async function configureMjolnir() {
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true) await registerUser('mjolnir', 'mjolnir', 'mjolnir', true)
} catch (e) { } catch (e) {
if (e.isAxiosError) { if (e.isAxiosError) {
console.log('Received error while registering', e);
if (e.response.data && e.response.data.errcode === 'M_USER_IN_USE') { if (e.response.data && e.response.data.errcode === 'M_USER_IN_USE') {
console.log('mjolnir already registered, skipping'); console.log('mjolnir already registered, skipping');
return; return;
} }
console.log('Received error while registering', e);
} }
throw e; throw e;
}; };

View File

@ -5,7 +5,6 @@ events {
http { http {
server { server {
listen 8081; listen 8081;
server_name localhost;
location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ { location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ {
# Abuse reports should be sent to Mjölnir. # Abuse reports should be sent to Mjölnir.
@ -25,7 +24,7 @@ http {
} }
location / { location / {
# Everything else should be sent to Synapse. # Everything else should be sent to Synapse.
proxy_pass http://localhost:9999; proxy_pass http://127.0.0.1:9999;
} }
} }
} }

View File

@ -70,6 +70,14 @@
"@types/yargs" "^16.0.0" "@types/yargs" "^16.0.0"
chalk "^4.0.0" chalk "^4.0.0"
"@selderee/plugin-htmlparser2@^0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.6.0.tgz#27e994afd1c2cb647ceb5406a185a5574188069d"
integrity sha512-J3jpy002TyBjd4N/p6s+s90eX42H2eRhK3SbsZuvTDv977/E8p2U3zikdiehyJja66do7FlxLomZLPlvl2/xaA==
dependencies:
domhandler "^4.2.0"
selderee "^0.6.0"
"@tootallnate/once@1": "@tootallnate/once@1":
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
@ -727,6 +735,11 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
discontinuous-range@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
integrity sha1-44Mx8IRLukm5qctxx3FYWqsbxlo=
doctrine@^3.0.0: doctrine@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
@ -1294,6 +1307,18 @@ html-to-text@^6.0.0:
lodash "^4.17.20" lodash "^4.17.20"
minimist "^1.2.5" minimist "^1.2.5"
html-to-text@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-8.0.0.tgz#5848681a5a38d657a7bb58cf5006d1c29fe64ce3"
integrity sha512-fEtul1OerF2aMEV+Wpy+Ue20tug134jOY1GIudtdqZi7D0uTudB2tVJBKfVhTL03dtqeJoF8gk8EPX9SyMEvLg==
dependencies:
"@selderee/plugin-htmlparser2" "^0.6.0"
deepmerge "^4.2.2"
he "^1.2.0"
htmlparser2 "^6.1.0"
minimist "^1.2.5"
selderee "^0.6.0"
htmlencode@^0.0.4: htmlencode@^0.0.4:
version "0.0.4" version "0.0.4"
resolved "https://registry.yarnpkg.com/htmlencode/-/htmlencode-0.0.4.tgz#f7e2d6afbe18a87a78e63ba3308e753766740e3f" resolved "https://registry.yarnpkg.com/htmlencode/-/htmlencode-0.0.4.tgz#f7e2d6afbe18a87a78e63ba3308e753766740e3f"
@ -1309,7 +1334,7 @@ htmlparser2@^4.1.0:
domutils "^2.0.0" domutils "^2.0.0"
entities "^2.0.0" entities "^2.0.0"
htmlparser2@^6.0.0: htmlparser2@^6.0.0, htmlparser2@^6.1.0:
version "6.1.0" version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
@ -1849,6 +1874,11 @@ mocha@^9.0.1:
yargs-parser "20.2.4" yargs-parser "20.2.4"
yargs-unparser "2.0.0" yargs-unparser "2.0.0"
moo@^0.5.0, moo@^0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
morgan@^1.10.0: morgan@^1.10.0:
version "1.10.0" version "1.10.0"
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
@ -1900,6 +1930,16 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
nearley@^2.20.1:
version "2.20.1"
resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"
integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==
dependencies:
commander "^2.19.0"
moo "^0.5.0"
railroad-diagrams "^1.0.0"
randexp "0.4.6"
negotiator@0.6.2: negotiator@0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@ -1994,6 +2034,14 @@ parse5@6.0.1:
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
parseley@^0.7.0:
version "0.7.0"
resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.7.0.tgz#9949e3a0ed05c5072adb04f013c2810cf49171a8"
integrity sha512-xyOytsdDu077M3/46Am+2cGXEKM9U9QclBDv7fimY7e+BBlxh2JcBp2mgNsmkyA9uvgyTjVzDi7cP1v4hcFxbw==
dependencies:
moo "^0.5.1"
nearley "^2.20.1"
parseurl@~1.3.3: parseurl@~1.3.3:
version "1.3.3" version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@ -2106,6 +2154,19 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
railroad-diagrams@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
integrity sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=
randexp@0.4.6:
version "0.4.6"
resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==
dependencies:
discontinuous-range "1.0.0"
ret "~0.1.10"
randombytes@^2.1.0: randombytes@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
@ -2211,6 +2272,11 @@ resolve@^1.3.2:
is-core-module "^2.2.0" is-core-module "^2.2.0"
path-parse "^1.0.6" path-parse "^1.0.6"
ret@~0.1.10:
version "0.1.15"
resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
rimraf@^3.0.2: rimraf@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
@ -2253,6 +2319,13 @@ saxes@^5.0.1:
dependencies: dependencies:
xmlchars "^2.2.0" xmlchars "^2.2.0"
selderee@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.6.0.tgz#f3bee66cfebcb6f33df98e4a1df77388b42a96f7"
integrity sha512-ibqWGV5aChDvfVdqNYuaJP/HnVBhlRGSRrlbttmlMpHcLuTqqbMH36QkSs9GEgj5M88JDYLI8eyP94JaQ8xRlg==
dependencies:
parseley "^0.7.0"
semver@^5.3.0, semver@^5.6.0: semver@^5.3.0, semver@^5.6.0:
version "5.7.1" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"