Intercept /report and display human-readable abuse reports in the moderation room - Resolves #38 (#135)

* Intercept /report and display human-readable abuse reports in the moderation room - Resolves #38
This commit is contained in:
David Teller 2021-10-07 14:42:08 +02:00 committed by GitHub
parent 725d400650
commit 06e5f00b2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1894 additions and 738 deletions

View File

@ -130,6 +130,36 @@ on your homeserver join. The antispam module will not join the rooms for you.
If you change the configuration, you will need to restart Synapse. You'll also need
to restart Synapse to install the plugin.
## Enabling readable abuse reports
Since version 1.2, Mjölnir offers the ability to replace the Matrix endpoint used
to report abuse and display it into a room, instead of requiring you to request
this data from an admin API.
This requires two configuration steps:
1. In your Mjölnir configuration file, typically `/etc/mjolnir/config/production.yaml`, copy and paste the `web` section from `default.yaml`, if you don't have it yet (it appears with version 1.20) and set `enabled: true` for both `web` and
`abuseReporting`.
2. Setup a reverse proxy that will redirect requests from `^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$` to `http://host:port/api/1/report/$1/$2`, where `host` is the host where you run Mjölnir, and `port` is the port you configured in `production.yaml`. For an example nginx configuration, see `test/nginx.conf`. It's the confirmation we use during runtime testing.
### Security note
This mechanism can extract some information from **unencrypted** rooms. We have
taken precautions to ensure that this cannot be abused: the only case in which
this feature will publish information from room *foo* is:
1. If it is used by a member of room *foo*; AND
2. If said member did witness the event; AND
3. If the event was unencrypted; AND
4. If the event was not redacted/removed/...
Essentially, this is a more restricted variant of the Admin APIs available on
homeservers.
However, if you are uncomfortable with this, please do not activate this feature.
Also, you should probably setup your `production.yaml` to ensure that the web
server can only receive requests from your reverse proxy (e.g. `localhost`).
## Development
TODO. It's a TypeScript project with a linter.

View File

@ -2,6 +2,11 @@
# pantalaimon if you're using that.
homeserverUrl: "https://matrix.org"
# Where the homeserver is located (client-server URL). NOT panalaimon.
rawHomeserverUrl: "https://matrix.org"
# The access token for the bot to use. Do not populate if using Pantalaimon.
accessToken: "YOUR_TOKEN_HERE"
@ -160,3 +165,30 @@ health:
# The HTTP status code which reports that the bot is not healthy/ready.
# Defaults to 418.
unhealthyStatus: 418
# Options for exposing web APIs.
web:
# Whether to enable web APIs.
enabled: false
# The port to expose the webserver on. Defaults to 8080.
port: 8080
# The address to listen for requests on. Defaults to only the current
# computer.
address: localhost
# Alternative setting to open to the entire web. Be careful,
# as this will increase your security perimeter:
#
# address: "0.0.0.0"
# A web API designed to intercept Matrix API
# POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
# and display readable abuse reports in the moderation room.
#
# If you wish to take advantage of this feature, you will need
# to configure a reverse proxy, see e.g. test/nginx.conf
abuseReporting:
# Whether to enable this feature.
enabled: false

View File

@ -1,9 +1,13 @@
# This configuration file is for the integration tests run by yarn:integration.
# Editing this will do nothing and you shouldn't use it as a template.
# For a template use default.yaml
# Unless you're working on the test suite, you should probably rather check
# default.yaml!
# Where the homeserver is located (client-server URL). This should point at
# pantalaimon if you're using that.
homeserverUrl: "http://localhost:9999"
homeserverUrl: "http://localhost:8081"
# Where the homeserver is located (client-server URL). NOT pantalaimon.
rawHomeserverUrl: "http://localhost:8081"
# Pantalaimon options (https://github.com/matrix-org/pantalaimon)
pantalaimon:
@ -159,3 +163,23 @@ health:
# The HTTP status code which reports that the bot is not healthy/ready.
# Defaults to 418.
unhealthyStatus: 418
# Options for exposing web APIs.
web:
# Whether to enable web APIs.
enabled: true
# The port to expose the webserver on. Defaults to 8080.
port: 8082
# 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
# A web API designed to intercept Matrix API
# POST /_matrix/client/r0/rooms/{roomId}/report/{eventId}
# and display readable abuse reports in the moderation room.
abuseReporting:
# Whether to enable this feature.
enabled: true

View File

@ -3,8 +3,14 @@ modules:
- name: synapse_antispam
build:
- cp -r synapse_antispam $MX_TEST_SYNAPSE_DIR
up:
# 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
run:
- yarn test-integration
- yarn test:integration
down:
finally:
- docker stop mjolnir-test-reverse-proxy
homeserver_config:
server_name: localhost:9999
pid_file: /data/homeserver.pid

View File

@ -1,6 +1,6 @@
{
"name": "mjolnir",
"version": "1.1.20",
"version": "1.2",
"description": "A moderation tool for Matrix",
"main": "lib/index.js",
"repository": "git@github.com:matrix-org/mjolnir.git",
@ -18,21 +18,25 @@
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/crypto-js": "^4.0.2",
"@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",
"mocha": "^9.0.1",
"ts-mocha": "^8.0.0",
"ts-node": "^10.2.1",
"tslint": "^6.1.3",
"typescript": "^4.3.5"
"typescript": "^4.3.5",
"typescript-formatter": "^7.2"
},
"dependencies": {
"config": "^3.3.6",
"escape-html": "^1.0.3",
"express": "^4.17",
"js-yaml": "^4.1.0",
"jsdom": "^16.6.0",
"matrix-bot-sdk": "^0.5.19"
}
}

View File

@ -25,6 +25,7 @@ import {
Permalinks,
UserID
} from "matrix-bot-sdk";
import BanList, { ALL_RULE_TYPES } from "./models/BanList";
import { applyServerAcls } from "./actions/ApplyAcl";
import { RoomUpdateError } from "./models/RoomUpdateError";
@ -39,6 +40,7 @@ import { UnlistedUserRedactionQueue } from "./queues/UnlistedUserRedactionQueue"
import { Healthz } from "./health/healthz";
import { EventRedactionQueue, RedactUserInRoom } from "./queues/EventRedactionQueue";
import * as htmlEscape from "escape-html";
import { WebAPIs } from "./webapis/WebAPIs";
export const STATE_NOT_STARTED = "not_started";
export const STATE_CHECKING_PERMISSIONS = "checking_permissions";
@ -70,7 +72,7 @@ export class Mjolnir {
private protectedJoinedRoomIds: string[] = [];
private explicitlyProtectedRoomIds: string[] = [];
private knownUnprotectedRooms: string[] = [];
private webapis: WebAPIs;
/**
* Adds a listener to the client that will automatically accept invitations.
* @param {MatrixClient} client
@ -160,6 +162,8 @@ export class Mjolnir {
this.automaticRedactionReasons.push(new MatrixGlob(reason.toLowerCase()));
}
// Setup bot.
client.on("room.event", this.handleEvent.bind(this));
client.on("room.message", async (roomId, event) => {
@ -214,6 +218,10 @@ export class Mjolnir {
this.displayName = profile['displayname'];
}
});
// Setup Web APIs
console.log("Creating Web APIs");
this.webapis = new WebAPIs(this.client);
}
public get lists(): BanList[] {
@ -241,14 +249,25 @@ export class Mjolnir {
return this.automaticRedactionReasons;
}
public start() {
return this.client.start().then(async () => {
/**
* Start Mjölnir.
*/
public async start() {
try {
// Start the bot.
await this.client.start();
// Start the web server.
console.log("Starting web server");
await this.webapis.start();
// Load the state.
this.currentState = STATE_CHECKING_PERMISSIONS;
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: Object | 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);
@ -265,18 +284,18 @@ export class Mjolnir {
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Checking permissions...");
await this.verifyPermissions(config.verboseLogging);
}
}).then(async () => {
this.currentState = STATE_SYNCING;
if (config.syncOnStartup) {
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Syncing lists...");
await this.syncLists(config.verboseLogging);
await this.enableProtections();
}
}).then(async () => {
this.currentState = STATE_RUNNING;
Healthz.isHealthy = true;
await logMessage(LogLevel.INFO, "Mjolnir@startup", "Startup complete. Now monitoring rooms.");
}).catch(async err => {
} catch (err) {
try {
LogService.error("Mjolnir", "Error during startup:");
LogService.error("Mjolnir", extractRequestError(err));
@ -286,7 +305,7 @@ export class Mjolnir {
console.error(e);
process.exit(1);
}
});
}
}
/**
@ -295,6 +314,7 @@ export class Mjolnir {
public stop() {
LogService.info("Mjolnir", "Stopping Mjolnir...");
this.client.stop();
this.webapis.stop();
}
public async addProtectedRoom(roomId: string) {
@ -310,7 +330,7 @@ export class Mjolnir {
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
additionalProtectedRooms.rooms.push(roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
await this.syncLists(config.verboseLogging);
@ -328,7 +348,7 @@ export class Mjolnir {
} catch (e) {
LogService.warn("Mjolnir", extractRequestError(e));
}
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = {rooms: []};
if (!additionalProtectedRooms || !additionalProtectedRooms['rooms']) additionalProtectedRooms = { rooms: [] };
additionalProtectedRooms.rooms = additionalProtectedRooms.rooms.filter(r => r !== roomId);
await this.client.setAccountData(PROTECTED_ROOMS_EVENT_TYPE, additionalProtectedRooms);
}
@ -355,7 +375,7 @@ export class Mjolnir {
private async getEnabledProtections() {
let enabled: string[] = [];
try {
const protections: Object|null = await this.client.getAccountData(ENABLED_PROTECTIONS_EVENT_TYPE);
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);
@ -387,7 +407,7 @@ export class Mjolnir {
if (persist) {
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
}
}
@ -396,10 +416,10 @@ export class Mjolnir {
if (idx >= 0) this.protections.splice(idx, 1);
const existing = this.protections.map(p => p.name);
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, {enabled: existing});
await this.client.setAccountData(ENABLED_PROTECTIONS_EVENT_TYPE, { enabled: existing });
}
public async watchList(roomRef: string): Promise<BanList|null> {
public async watchList(roomRef: string): Promise<BanList | null> {
const joinedRooms = await this.client.getJoinedRooms();
const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null;
@ -424,7 +444,7 @@ export class Mjolnir {
return list;
}
public async unwatchList(roomRef: string): Promise<BanList|null> {
public async unwatchList(roomRef: string): Promise<BanList | null> {
const permalink = Permalinks.parseUrl(roomRef);
if (!permalink.roomIdOrAlias) return null;
@ -450,14 +470,14 @@ export class Mjolnir {
this.applyUnprotectedRooms();
try {
const accountData: Object|null = await this.client.getAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId);
const accountData: Object | 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
}
await logMessage(LogLevel.WARN, "Mjolnir", `Not protecting ${roomId} - it is a ban list that this bot did not create. Add the room as protected if it is supposed to be protected. This warning will not appear again.`, roomId);
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, {warned: true});
await this.client.setAccountData(WARN_UNPROTECTED_ROOM_EVENT_PREFIX + roomId, { warned: true });
}
private applyUnprotectedRooms() {
@ -711,7 +731,7 @@ export class Mjolnir {
}
}
private async printActionResult(errors: RoomUpdateError[], title: string|null = null, logAnyways = false) {
private async printActionResult(errors: RoomUpdateError[], title: string | null = null, logAnyways = false) {
if (errors.length <= 0) return false;
if (!logAnyways) {

View File

@ -1,5 +1,5 @@
/*
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
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.
@ -17,8 +17,17 @@ limitations under the License.
import * as config from "config";
import { MatrixClient } from "matrix-bot-sdk";
/**
* The configuration, as read from production.yaml
*
* See file default.yaml for the documentation on individual options.
*/
// The object is magically generated by external lib `config`
// from the file specified by `NODE_ENV`, e.g. production.yaml
// or harness.yaml.
interface IConfig {
homeserverUrl: string;
rawHomeserverUrl: string;
accessToken: string;
pantalaimon: {
use: boolean;
@ -60,6 +69,14 @@ interface IConfig {
unhealthyStatus: number;
};
};
web: {
enabled: boolean;
port: number;
address: string;
abuseReporting: {
enabled: boolean;
}
}
/**
* Config options only set at runtime. Try to avoid using the objects
@ -72,6 +89,7 @@ interface IConfig {
const defaultConfig: IConfig = {
homeserverUrl: "http://localhost:8008",
rawHomeserverUrl: "http://localhost:8008",
accessToken: "NONE_PROVIDED",
pantalaimon: {
use: false,
@ -113,6 +131,14 @@ const defaultConfig: IConfig = {
unhealthyStatus: 418,
},
},
web: {
enabled: false,
port: 8080,
address: "localhost",
abuseReporting: {
enabled: false,
}
},
// Needed to make the interface happy.
RUNTIME: {

243
src/webapis/WebAPIs.ts Normal file
View File

@ -0,0 +1,243 @@
/*
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 { Server } from "http";
import * as express from "express";
import { JSDOM } from 'jsdom';
import { MatrixClient } from "matrix-bot-sdk";
import config from "../config";
/**
* A common prefix for all web-exposed APIs.
*/
const API_PREFIX = "/api/1";
const AUTHORIZATION: RegExp = new RegExp("Bearer (.*)");
export class WebAPIs {
private webController: express.Express = express();
private httpServer?: Server;
constructor(private client: MatrixClient) {
// Setup JSON parsing.
this.webController.use(express.json());
}
/**
* Start accepting requests to the Web API.
*/
public async start() {
if (!config.web.enabled) {
return;
}
this.httpServer = this.webController.listen(config.web.port, config.web.address);
// Configure /report API.
if (config.web.abuseReporting.enabled) {
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id...`);
this.webController.post(`${API_PREFIX}/report/:room_id/:event_id`, async (request, response) => {
console.debug(`Received a message on ${API_PREFIX}/report/:room_id/:event_id`, request.params);
await this.handleReport({ request, response, roomId: request.params.room_id, eventId: request.params.event_id })
});
console.log(`Configuring ${API_PREFIX}/report/:room_id/:event_id... DONE`);
}
}
public stop() {
if (this.httpServer) {
console.log("Stopping WebAPIs.");
this.httpServer.close();
this.httpServer = undefined;
}
}
/**
* Handle a call to the /report API.
*
* In case of success, respond an empty JSON body.
*
* @param roomId The room in which the reported event took place. Already extracted from the URL.
* @param eventId The event. Already extracted from the URL.
* @param request The request. Its body SHOULD hold an object `{reason?: string}`
* @param response The response. Used to propagate HTTP success/error.
*/
async handleReport({ roomId, eventId, request, response }: { roomId: string, eventId: string, request: express.Request, response: express.Response }) {
// To display any kind of useful information, we need
//
// 1. The reporter id;
// 2. The accused id, to be able to warn/kick/ban them if necessary;
// 3. The content of the event **if the room is unencrypted**.
try {
let reporterId;
let event;
{
// -- Create a client on behalf of the reporter.
// We'll use it to confirm the authenticity of the report.
let accessToken;
// Authentication mechanism 1: Request header.
let authorization = request.get('Authorization');
if (authorization) {
[, accessToken] = AUTHORIZATION.exec(authorization)!;
} else {
// Authentication mechanism 2: Access token as query parameter.
accessToken = request.query["access_token"];
}
// Create a client dedicated to this report.
//
// VERY IMPORTANT NOTES
//
// We're impersonating the user to get the context of the report.
//
// For privacy's sake, we MUST ensure that:
//
// - we DO NOT sync with this client, as this would let us
// snoop on messages other than the context of the report;
// - we DO NOT associate a crypto store (e.g. Pantalaimon),
// as this would let us read encrypted messages;
// - this client is torn down as soon as possible to avoid
// any case in which it could somehow be abused if a
// malicious third-party gains access to Mjölnir.
//
// Rationales for using this mechanism:
//
// 1. This /report interception feature can only be setup by someone
// who already controls the server. In other words, if they wish
// to snoop on unencrypted messages, they can already do it more
// easily at the level of the proxy.
// 2. The `reporterClient` is used only to provide
// - identity-checking; and
// - features that are already available in the Synapse Admin API
// (possibly in the Admin APIs of other homeservers, I haven't checked)
// so we are not extending the abilities of Mjölnir
// 3. We are avoiding the use of the Synapse Admin API to ensure that
// this feature can work with all homeservers, not just Synapse.
let reporterClient = new MatrixClient(config.rawHomeserverUrl, accessToken);
reporterClient.start = () => {
throw new Error("We MUST NEVER call start on the reporter client");
};
reporterId = await reporterClient.getUserId();
/*
Past this point, the following invariants hold:
- The report was sent by a Matrix user.
- The identity of the Matrix user who sent the report is stored in `reporterId`.
*/
// Now, let's gather more info on the event.
// IMPORTANT: The following call will return the event without decyphering it, so we're
// not obtaining anything that we couldn't also obtain through a homeserver's Admin API.
//
// By doing this with the reporterClient, we ensure that this feature of Mjölnir can work
// with all Matrix homeservers, rather than just Synapse.
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"];
// 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.
response.status(200).json({});
} catch (ex) {
console.warn("Error responding to an abuse report", roomId, eventId, ex);
}
}
}

View File

@ -0,0 +1,111 @@
import { strict as assert } from "assert";
import config from "../../src/config";
import { matrixClient, mjolnir } from "./mjolnirSetupUtils";
import { newTestUser } from "./clientHelper";
/**
* Test the ability to turn abuse reports into room messages.
*/
describe("Test: Reporting abuse", async () => {
it('Mjölnir intercepts abuse reports', 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 few users and a room.
let goodUser = await newTestUser(false, "reporting-abuse-good-user");
let badUser = await newTestUser(false, "reporting-abuse-bad-user");
let goodUserId = await goodUser.getUserId();
let badUserId = await badUser.getUserId();
let roomId = await goodUser.createRoom({ invite: [await badUser.getUserId()] });
await goodUser.inviteUser(await badUser.getUserId(), roomId);
await badUser.joinRoom(roomId);
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 badText2 = `BAD: ${Math.random()}`; // Will be reported as abuse.
let goodEventId = await goodUser.sendText(roomId, goodText);
let badEventId = await badUser.sendText(roomId, badText);
let badEventId2 = await badUser.sendText(roomId, badText2);
let badEvent2Comment = `COMMENT: ${Math.random()}`;
console.log("Test: Reporting abuse - send reports");
let reportsToFind = []
// Time to report, first without a comment, then with one.
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId)}`);
reportsToFind.push({
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId,
text: badText,
comment: null,
});
} catch (e) {
console.error("Could not send first report", e.body || e);
throw e;
}
try {
await goodUser.doRequest("POST", `/_matrix/client/r0/rooms/${encodeURIComponent(roomId)}/report/${encodeURIComponent(badEventId2)}`, "", {
reason: badEvent2Comment
});
reportsToFind.push({
reporterId: goodUserId,
accusedId: badUserId,
eventId: badEventId2,
text: badText2,
comment: badEvent2Comment,
});
} catch (e) {
console.error("Could not send second report", e.body || e);
throw e;
}
// FIXME: Also test with embedded HTML.
console.log("Test: Reporting abuse - wait");
await new Promise(resolve => setTimeout(resolve, 1000));
let found = [];
let regexp = /^User ([^ ]*) \(([^ ]*)\) reported event ([^ ]*).*sent by user ([^ ]*) \(([^ ]*)\).*\n.*\nReporter commented: (.*)/m;
for (let toFind of reportsToFind) {
for (let event of notices) {
if ("content" in event && "body" in event.content) {
let body = event.content.body as string;
let match = body.match(regexp);
if (!match) {
// Not a report, skipping.
continue;
}
let [, reporterDisplay, reporterId, eventId, accusedDisplay, accusedId, reason] = match;
if (eventId != toFind.eventId) {
// Different event id, skipping.
continue;
}
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) {
assert.equal(reason, toFind.comment, "The report should contain the comment we added");
}
found.push(toFind);
break;
}
}
}
assert.deepEqual(reportsToFind, found);
})
});

View File

@ -7,6 +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.
* 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.
*
* @param username The username to give the user.
* @param displayname The displayname to give the user.
* @param password The password to use.
@ -30,14 +31,19 @@ export async function registerUser(username: string, displayname: string, passwo
/**
* Register a new test user with a unique username.
*
* @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.
*/
export async function registerNewTestUser(isAdmin: boolean) {
export async function registerNewTestUser(isAdmin: boolean, label: string = "") {
let isUserValid = false;
let username;
if (label != "") {
label += "-";
}
do {
username = `mjolnir-test-user-${Math.floor(Math.random() * 100000)}`
username = `mjolnir-test-user-${label}${Math.floor(Math.random() * 100000)}`
await registerUser(username, username, username, isAdmin).then(_ => isUserValid = true).catch(e => {
if (e.isAxiosError && e?.response?.data?.errcode === 'M_USER_IN_USE') {
LogService.debug("test/clientHelper", `${username} already registered, trying another`);
@ -53,11 +59,13 @@ export async function registerNewTestUser(isAdmin: boolean) {
/**
* Registers a unique 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): Promise<MatrixClient> {
const username = await registerNewTestUser(isAdmin);
export async function newTestUser(isAdmin: boolean = false, label: string = ""): Promise<MatrixClient> {
const username = await registerNewTestUser(isAdmin, label);
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
return await pantalaimon.createClientWithCredentials(username, username);
}

View File

@ -9,9 +9,11 @@ import { makeMjolnir, teardownManagementRoom } from "./mjolnirSetupUtils";
export const mochaHooks = {
beforeEach: [
async function() {
this.managementRoomAlias = config.managementRoom
this.mjolnir = await makeMjolnir()
this.mjolnir.start()
console.log("mochaHooks.beforeEach");
this.managementRoomAlias = config.managementRoom;
this.mjolnir = await makeMjolnir();
this.mjolnir.start();
console.log("mochaHooks.beforeEach DONE");
}
],
afterEach: [

View File

@ -1,7 +1,7 @@
import config from "../../src/config";
import { newTestUser, noticeListener } from "./clientHelper"
describe("help command", () => {
describe("Test: !help command", () => {
let client;
before(async function () {
client = await newTestUser(true);

View File

@ -45,17 +45,33 @@ export async function ensureAliasedRoomExists(client: MatrixClient, alias: strin
}
async function configureMjolnir() {
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true).catch(e => {
if (e.isAxiosError && e.response.data.errcode === 'M_USER_IN_USE') {
console.log('mjolnir already registered, skipping');
} else {
throw e;
try {
await registerUser('mjolnir', 'mjolnir', 'mjolnir', true)
} catch (e) {
if (e.isAxiosError) {
console.log('Received error while registering', e);
if (e.response.data && e.response.data.errcode === 'M_USER_IN_USE') {
console.log('mjolnir already registered, skipping');
return;
}
}
});
throw e;
};
}
export function mjolnir(): Mjolnir | null {
return globalMjolnir;
}
export function matrixClient(): MatrixClient | null {
return globalClient;
}
let globalClient: MatrixClient | null
let globalMjolnir: Mjolnir | null;
export async function makeMjolnir() {
/**
* Return a test instance of Mjolnir.
*/
export async function makeMjolnir(): Promise<Mjolnir> {
await configureMjolnir();
LogService.setLogger(new RichConsoleLogger());
LogService.setLevel(LogLevel.fromString(config.logLevel, LogLevel.DEBUG));
@ -63,7 +79,10 @@ export async function makeMjolnir() {
const pantalaimon = new PantalaimonClient(config.homeserverUrl, new MemoryStorageProvider());
const client = await pantalaimon.createClientWithCredentials(config.pantalaimon.username, config.pantalaimon.password);
await ensureAliasedRoomExists(client, config.managementRoom);
return await Mjolnir.setupMjolnirFromConfig(client);
let mjolnir = await Mjolnir.setupMjolnirFromConfig(client);
globalClient = client;
globalMjolnir = mjolnir;
return mjolnir;
}
/**

31
test/nginx.conf Normal file
View File

@ -0,0 +1,31 @@
events {
}
http {
server {
listen 8081;
server_name localhost;
location ~ ^/_matrix/client/r0/rooms/([^/]*)/report/(.*)$ {
# Abuse reports should be sent to Mjölnir.
# Add CORS, otherwise a browser will refuse this request.
add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this.
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days
# Alias the regexps, to ensure that they're not rewritten.
set $room_id $1;
set $event_id $2;
proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id;
}
location / {
# Everything else should be sent to Synapse.
proxy_pass http://localhost:9999;
}
}
}

View File

@ -5,6 +5,7 @@
"emitDecoratorMetadata": true,
"module": "commonjs",
"moduleResolution": "node",
"newLine": "LF",
"noImplicitReturns": true,
"noUnusedLocals": true,
"target": "es2015",

1991
yarn.lock

File diff suppressed because it is too large Load Diff